Что на самом деле означают имена в alg
JWT — это JWS (JSON Web Signature) с полезной нагрузкой в формате JSON: header.payload.signature, каждая часть закодирована в base64url. Параметр заголовка alg называет схему подписи, а реестр его значений описан в RFC 7518 (JSON Web Algorithms). Четыре значения, которые встречаются на практике:
| alg | Схема | Тип ключа | Размер подписи |
|---|---|---|---|
HS256 | HMAC с SHA-256 | Один общий секрет (симметричный) | 32 байта |
RS256 | RSASSA-PKCS1-v1_5 с SHA-256 | Пара ключей RSA | 256 байт (RSA-2048) |
PS256 | RSASSA-PSS с SHA-256 | Пара ключей RSA | 256 байт (RSA-2048) |
ES256 | ECDSA на P-256 с SHA-256 | Пара ключей EC | 64 байта (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) — издателя, аудиторию, срок действия — в том же вызове.
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",
});
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:
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 становится тем, чем и должен быть, — скромным инженерным компромиссом между байтами и миллисекундами.