Перейти к содержимому
ecdsa.com
← Все статьи

ES256 или RS256 для JWT: как выбрать и не обжечься

Две буквы в заголовке JWT определяют размер токена, то, как будет устроено управление ключами, и — если настроить проверку небрежно — можно ли вообще подделать ваши токены. Разберём, что на самом деле означают ES256 и RS256 и как пользоваться ими безопасно.

Что на самом деле означают имена в alg

JWT — это JWS (JSON Web Signature) с полезной нагрузкой в формате JSON: header.payload.signature, каждая часть закодирована в base64url. Параметр заголовка alg называет схему подписи, а реестр его значений описан в RFC 7518 (JSON Web Algorithms). Четыре значения, которые встречаются на практике:

algСхемаТип ключаРазмер подписи
HS256HMAC с SHA-256Один общий секрет (симметричный)32 байта
RS256RSASSA-PKCS1-v1_5 с SHA-256Пара ключей RSA256 байт (RSA-2048)
PS256RSASSA-PSS с SHA-256Пара ключей RSA256 байт (RSA-2048)
ES256ECDSA на P-256 с SHA-256Пара ключей EC64 байта (raw r‖s)

В этой таблице скрыты два структурных момента. Во-первых, HS256 устроен принципиально иначе: он симметричный, поэтому любая сторона, которая может проверить токен, может и выпустить его. Он подходит, только если издатель и проверяющая сторона — один и тот же сервис. RS256, PS256 и ES256 асимметричные: у проверяющих сторон есть только открытый ключ, и его можно свободно им раздавать, например через эндпоинт JWKS.

Во-вторых, имена ES однозначно задают кривую: ES256 означает P-256 с SHA-256, ES384 — P-384 с SHA-384, а ES512 — обратите внимание на числа — 521-битную кривую P-521 с SHA-512. PS256 использует тот же ключевой материал RSA, что и RS256, но с более новым дополнением PSS; если ваша экосистема его поддерживает, PSS — лучший режим RSA. (Более глубокое сравнение алгоритмов за пределами JWT — в статье ECDSA или RSA.)

Размер токена: подпись 64 байта против 256 байт

Подпись ES256 — это 64 байта в формате raw, около 86 символов base64url. Подпись RS256 с распространённым 2048-битным ключом занимает 256 байт (около 342 символов), а с RSA-3072 — 384 байта. Заголовок и полезная нагрузка в обоих случаях одинаковы, поэтому переход RS256 → ES256 обычно сокращает каждый токен на ~250 байт. Это важно, когда токены передаются в cookie (лимит 4 КБ), в заголовках Authorization при каждом запросе, в QR-кодах или в мобильном и IoT-трафике. Открытый ключ ES256 тоже намного меньше, поэтому документы JWKS остаются компактными.

Скорость подчиняется той же закономерности, что описана в руководстве ECDSA или RSA: ES256 быстрее подписывает, RS256 быстрее проверяет. Для издателя токенов, который подписывает их в больших объёмах, ES256 — более выгодная сторона компромисса.

Правило, которое предотвращает большинство катастроф с JWT: закрепите алгоритм

Классическая ошибка конфигурации JWT — позволить токену самому решать, как его будут проверять. Заголовок alg — это входные данные под контролем атакующего: он приходит внутри того самого токена, который вы ещё не проверили. Проверяющая сторона, которая читает alg из заголовка и выбирает по нему ветку обработки, когда один объект ключа обслуживает несколько семейств алгоритмов, сама разрушает свои гарантии. Известный случай подмены алгоритма: сервис проверяет токены RS256 открытым ключом, но его библиотека принимает и HS256 с тем же объектом ключа, считая байты открытого ключа RSA секретом HMAC. Открытый ключ по своей природе общедоступен, поэтому кто угодно может вычислить подходящий тег HS256 и выпускать токены, которые сервис примет.

Исправление чисто механическое, и оно закреплено в RFC 8725 (JWT Best Current Practices):

  • Проверяйте по явному списку разрешённых алгоритмов (в идеале из одного значения), заданному на стороне сервера и никогда не взятому из токена.
  • Привяжите ключи к алгоритмам. В JWKS укажите для каждого ключа его alg и kid; ключ, опубликованный для ES256, никогда не должен подходить ни для чего другого. Помогают и проверки типа ключа: ключ EC P-256 физически не может проверить подпись RS256, и это ещё один довод в пользу ES256 в смешанных средах.
  • Явно проверяйте утверждения (claims) — издателя, аудиторию, срок действия — в том же вызове.
node — jose: список разрешённых алгоритмов закреплён на стороне проверки
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json"));

const { payload } = await jwtVerify(token, jwks, {
  algorithms: ["ES256"],          // fixed by the verifier, not by the token
  issuer: "https://issuer.example",
  audience: "api://orders",
});
python — PyJWT: то же правило
import jwt

payload = jwt.decode(
    token,
    public_key_pem,
    algorithms=["ES256"],   # required parameter in modern PyJWT — for good reason
    audience="api://orders",
    issuer="https://issuer.example",
)

Почему значение «none» недопустимо никогда

RFC 7518 регистрирует и "alg": "none" — «незащищённый JWS» (unsecured JWS), токен с пустой частью подписи. Он существует для редкого случая, когда целостность обеспечивается каким-то внешним уровнем. Проверяющая сторона, которая принимает none, принимает неподписанные токены: кто угодно может составить любую полезную нагрузку и предъявить её. Ранние библиотеки JWT (около 2015 года) печально прославились тем, что считали none просто ещё одним алгоритмом, и из-за этого неподписанные токены по умолчанию проходили проверку. Современные библиотеки отвергают его, если он не включён явно, а при списке разрешённых алгоритмов ["ES256"] его невозможно выбрать. Если вы проводите аудит кодовой базы, любой путь выполнения, который может дойти до none или до пустого списка разрешённых алгоритмов, нужно отметить в отчёте как проблему.

Ловушка ES256: подписи JWS — это raw r‖s, а не DER

Математически подпись ECDSA — это пара целых чисел (r, s), и для этой пары есть две распространённые байтовые кодировки. RFC 7518 § 3.4 говорит однозначно: в JWS подпись ES256 — это r и s в виде двух 32-байтных целых чисел big-endian, записанных подряд — ровно 64 байта, которые затем кодируются в base64url. Это не структура ASN.1 DER SEQUENCE, которую по умолчанию выдают OpenSSL, Signature в Java и crypto.sign() в Node.

Это одна из самых частых причин ошибки «invalid signature» в самописном коде для JWT: токен несёт DER-блок длиной ~70 байт (или проверяющая сторона передаёт 64-байтную raw-подпись в API, который ждёт DER), и проверка не проходит, хотя ключ, сообщение и алгоритм верны. Если вы подписываете встроенным модулем crypto в Node, сразу запросите формат JWS:

node — 64-байтная подпись ES256, готовая для JWS
import { createPrivateKey, sign } from "node:crypto";

const key = createPrivateKey(pem); // P-256 private key
const signingInput = Buffer.from(`${headerB64}.${payloadB64}`);

const signature = sign("sha256", signingInput, {
  key,
  dsaEncoding: "ieee-p1363",   // raw r||s — what JWS requires (default is DER)
});
console.log(signature.length); // 64

Встретили подпись не той формы в реальной системе? Конвертер DER ⇄ raw преобразует подписи в обе стороны и показывает значения r и s; подробности на уровне байтов, а также тонкость с high-S разобраны в статье Форматы подписей ECDSA: DER или raw. А чтобы проверить настоящий токен (заголовок, утверждения и подпись ES256 вашим открытым ключом), вставьте его в отладчик JWT.

Выбор и переход без поломок

Перевод существующего развёртывания с RS256 на ES256 — это задача ротации ключей, а не одномоментное переключение:

  • Опубликуйте новый ключ EC в JWKS рядом с ключом RSA, у каждого с правильными значениями kid и alg, и дайте потребителям время обновить кеши JWKS.
  • Начните выпускать токены ES256 с новым kid. Проверяющие стороны, которые выбирают ключ по kid и требуют алгоритм, закреплённый за ключом, продолжат принимать оба поколения токенов, и каждое будет проверяться ровно одним ключом.
  • Когда истечёт срок действия всех токенов RS256, удалите ключ RSA из JWKS и сократите список разрешённых алгоритмов у проверяющей стороны обратно до ["ES256"].

Главное, что стоит запомнить: алгоритм выбирает проверяющая сторона, а не токен. Если это сделано правильно, выбор между ES256 и RS256 становится тем, чем и должен быть, — скромным инженерным компромиссом между байтами и миллисекундами.