Коротко. Подписывайте ES256, если токены уходят из-под вашего контроля: его проверяет каждая протестированная нами библиотека JOSE, любой крупный облачный KMS и HSM может хранить его ключ, а стандарты вроде FAPI 2.0 включают его в свои списки. Выбирайте Ed25519, если все проверяющие стороны под вашим контролем и вы можете хранить ключ программно или в AWS KMS: его подписи детерминированы, и их нельзя превратить во вторую действительную форму, а это исключает два класса ошибок ECDSA. Если вы выбрали Ed25519, пока публикуйте его как "alg": "EdDSA", а на стороне проверки принимайте и EdDSA, и новое имя Ed25519. Меньше половины протестированных нами библиотек пока понимают новое имя.
Все токены и ключи на этой странице сгенерированы специально для неё. Результаты по библиотекам получены запусками в официальных образах Docker 20 октября 2026 года; версии указаны рядом с каждым результатом.
Что означают два имени
| ES256 | EdDSA / Ed25519 | |
|---|---|---|
| Схема | ECDSA на P-256 с SHA-256 | EdDSA на Edwards25519 (RFC 8032, § 5.1) |
| Определение в JOSE | RFC 7518 § 3.4 | RFC 8037, имя изменено в RFC 9864 |
| Статус в реестре IANA JOSE | рекомендуется (Recommended+) | EdDSA: устарел (Deprecated); Ed25519: необязателен (Optional) |
| JWK | "kty": "EC", crv, x, y | "kty": "OKP", "crv": "Ed25519", x |
| Подпись | r ‖ s, 64 байта | R ‖ S, 64 байта |
| Одобрен в FIPS 186-5 | да | да |
Смена имени важнее, чем кажется. В RFC 8037 для Ed25519 и Ed448 зарегистрировано одно общее значение EdDSA, поэтому сам alg не говорит, какую кривую использовать, — это определяет crv ключа. RFC 9864 (октябрь 2025 года) объявил это полиморфное имя устаревшим и зарегистрировал полностью определённые Ed25519 и Ed448. Формат ключа не меняется, меняется только строка alg. ES256 в JOSE и так был полностью определённым и в переименовании не нуждается. В COSE RFC 9864 сделал то же самое с ECDSA: ES256 (-7) объявлен устаревшим в пользу ESP256 (-9); это важно для WebAuthn и учётных данных на основе COSE, а не для JWT.
Размер: одинаковый там, где это важно
Обе подписи занимают 64 байта, или 86 символов base64url. Различаются только строка заголовка и открытый ключ:
ES256 token 253 chars, signature 64 bytes (86 chars)
EdDSA token 253 chars, signature 64 bytes (86 chars)
Ed25519 token 256 chars, signature 64 bytes (86 chars)
es public JWK 137 chars: {"kty":"EC","x":"B8UtJ0UT7zKuYn6NgB7h0E-L1-UF85FC-aoxjO7a2AE","y":"gwHfmffA6Vv9eWTKD_XCwGoLj5JtI1DQtGVq27icAU4","crv":"P-256","kid":"es"}
ed public JWK 90 chars: {"kty":"OKP","crv":"Ed25519","x":"NzIuH6R_919ooSpy2wU9iMMhGjaS3yXnjLF0AiqAC04","kid":"ed"}
Открытый ключ Ed25519 занимает 32 байта, ключ P-256 — две 32-байтные координаты, поэтому JWKS сокращается примерно на 47 символов на каждый ключ. Выбирать алгоритм из-за такой разницы никому не стоит. С RS256 картина другая: подпись RSA-2048 занимает 256 байт, поэтому для токенов в cookie и заголовках обе кривые выигрывают у него (см. ES256 или RS256).
Детерминизм: где ECDSA может дать сбой, а EdDSA нет
Для каждой подписи ECDSA нужно новое секретное число. Стоит хотя бы раз использовать его повторно или допустить, чтобы оно было хоть немного предсказуемым, — и закрытый ключ можно вычислить по общедоступным подписям; в статье Повторное использование nonce в ECDSA разобраны алгебра и реальные инциденты. Большинство библиотек JOSE берут это число из системного генератора случайных чисел, поэтому одни и те же входные данные каждый раз дают другую подпись. RFC 6979 описывает детерминированный способ его вывести, но используют ли этот способ ваша библиотека, ваш HSM или ваш KMS, от вас не зависит.
EdDSA по своему устройству выводит значение для каждой подписи из закрытого ключа и сообщения (RFC 8032 § 5.1.6). Тот же ключ и то же сообщение дают ту же подпись, и генератор случайных чисел в этом не участвует. Так устраняется тот механизм сбоя, который стоял за утечкой ключа PlayStation 3 и утечками ключей из Android-кошельков в 2013 году. Но устраняются не все риски: детерминированная подпись сильнее подвержена атакам с внесением сбоев (fault injection) на оборудовании, к которому атакующий может получить физический доступ, поэтому в некоторых более новых схемах случайность снова добавляют. Для издателей JWT, работающих на серверах, детерминизм — явный выигрыш.
Пластичность: второй действительный токен даром
У подписи ECDSA (r, s) есть двойник: (r, n − s) проходит проверку с тем же ключом и тем же сообщением, где n — порядок кривой. Библиотеки JOSE двойника не отвергают. Ed25519 в том виде, как он описан в RFC 8032, требует, чтобы проверяющая сторона отвергала любое S, которое не меньше порядка группы L, поэтому прибавление L к S не даёт второй действительной подписи. Проверяем оба утверждения на практике:
// Determinism and malleability of ES256 vs EdDSA tokens, checked with jose and jsonwebtoken.
import { readFileSync } from "node:fs";
import { SignJWT, generateKeyPair, importJWK, jwtVerify } from "jose";
import jwt from "jsonwebtoken";
const claims = { sub: "user-42", exp: 1924992000 };
const es = await generateKeyPair("ES256");
const ed = await generateKeyPair("Ed25519");
const sig = (t) => t.split(".")[2].slice(0, 16) + "...";
const signTwice = async (alg, key) => [
await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
];
const [es1, es2] = await signTwice("ES256", es.privateKey);
const [ed1, ed2] = await signTwice("EdDSA", ed.privateKey);
console.log(`ES256 same input twice: ${sig(es1)} vs ${sig(es2)} -> identical: ${es1 === es2}`);
console.log(`EdDSA same input twice: ${sig(ed1)} vs ${sig(ed2)} -> identical: ${ed1 === ed2}`);
// Second valid signature for the same ES256 token: (r, n - s).
const n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
const keys = JSON.parse(readFileSync("out/keys.json", "utf8"));
const flipS = (token) => {
const [h, p, s] = token.split(".");
const raw = Buffer.from(s, "base64url");
const sValue = BigInt("0x" + raw.subarray(32).toString("hex"));
const flipped = Buffer.from((n - sValue).toString(16).padStart(64, "0"), "hex");
return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), flipped]).toString("base64url")}`;
};
// For Ed25519, S + L encodes the same scalar mod L; RFC 8032 requires S < L.
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
const addL = (token) => {
const [h, p, s] = token.split(".");
const raw = Buffer.from(s, "base64url");
const S = BigInt("0x" + Buffer.from(raw.subarray(32)).reverse().toString("hex"));
const bumped = Buffer.from((S + L).toString(16).padStart(64, "0"), "hex").reverse();
return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), bumped]).toString("base64url")}`;
};
const check = async (label, token, alg, keyName) => {
const out = [];
try {
await jwtVerify(token, await importJWK(keys[keyName].jwk, alg), { algorithms: [alg] });
out.push("jose: accepted");
} catch (e) {
out.push(`jose: ${e.code}`);
}
if (alg === "ES256") {
try {
jwt.verify(token, keys[keyName].pem, { algorithms: [alg] });
out.push("jsonwebtoken: accepted");
} catch (e) {
out.push(`jsonwebtoken: ${e.message}`);
}
}
console.log(`${label.padEnd(30)} ${out.join(" | ")}`);
};
const esFlipped = flipS(tokens.ES256);
const edBumped = addL(tokens.EdDSA);
console.log(`ES256 token and its (r, n-s) twin differ: ${tokens.ES256 !== esFlipped}`);
await check("ES256 original", tokens.ES256, "ES256", "es");
await check("ES256 with s replaced by n-s", esFlipped, "ES256", "es");
await check("EdDSA original", tokens.EdDSA, "EdDSA", "ed");
await check("EdDSA with S replaced by S+L", edBumped, "EdDSA", "ed");
ES256 same input twice: b7-5MuAwyrsT26t_... vs ct-P2K38kCt5LlK4... -> identical: false
EdDSA same input twice: riZF8DpT02Grlv3U... vs riZF8DpT02Grlv3U... -> identical: true
ES256 token and its (r, n-s) twin differ: true
ES256 original jose: accepted | jsonwebtoken: accepted
ES256 with s replaced by n-s jose: accepted | jsonwebtoken: accepted
EdDSA original jose: accepted
EdDSA with S replaced by S+L jose: ERR_JWS_SIGNATURE_VERIFICATION_FAILED
Двойник токена ES256 несёт те же утверждения, поэтому подделать с его помощью токен никто не сможет. Зато он ломает любую логику, которая считает строку токена его идентификатором. Если вы отзываете утёкший токен, сохраняя его хеш, или отсеиваете дубликаты запросов по строке токена, владелец токена может предъявить двойника и пройти проверку. Идентифицируйте токены по утверждению jti и никогда — по их байтам. Нормализация low-S, которую требует Bitcoin, не входит в JOSE, и ни одна из протестированных нами библиотек её не применяет.
Скорость: не решающий фактор
Насколько верно расхожее утверждение, что Ed25519 «вдвое быстрее», зависит от реализации. Подпись и проверка входных данных для подписи JWT (signing input) на одной и той же машине (arm64, одно ядро, внутри Docker):
| Реализация | Подпись ES256 | Проверка ES256 | Подпись Ed25519 | Проверка Ed25519 |
|---|---|---|---|---|
| Node.js 24.22, OpenSSL 3.5.8 | 64 840/с | 23 163/с | 60 249/с | 23 398/с |
| Стандартная библиотека Go 1.27.3 | 53 877/с | 25 778/с | 75 218/с | 35 639/с |
С OpenSSL оба алгоритма отличаются друг от друга в пределах нескольких процентов; в Go Ed25519 работает примерно на 40 % быстрее, чем P-256. В любом случае одно ядро проверяет больше двадцати тысяч токенов в секунду — гораздо больше, чем пропустят сеть и разбор JSON вокруг этой проверки. На вашем оборудовании абсолютные значения будут другими. Замер в Node:
// Single-thread sign and verify throughput of a JWS signing input, ES256 vs Ed25519, node:crypto (OpenSSL).
import { generateKeyPairSync, sign, verify } from "node:crypto";
const input = Buffer.from("eyJhbGciOiJFUzI1NiJ9." + Buffer.from(JSON.stringify({ sub: "user-42", exp: 1924992000 })).toString("base64url"));
const algs = {
ES256: { keys: generateKeyPairSync("ec", { namedCurve: "P-256" }), hash: "sha256", opts: { dsaEncoding: "ieee-p1363" } },
Ed25519: { keys: generateKeyPairSync("ed25519"), hash: null, opts: {} },
};
const rate = (fn) => {
for (let i = 0; i < 500; i++) fn(); // warm-up
let count = 0;
const start = process.hrtime.bigint();
while (process.hrtime.bigint() - start < 2_000_000_000n) {
fn();
count++;
}
return Math.round(count / 2);
};
console.log(`node ${process.version}, OpenSSL ${process.versions.openssl}, ${process.arch}`);
for (const [name, { keys, hash, opts }] of Object.entries(algs)) {
const signature = sign(hash, input, { key: keys.privateKey, ...opts });
const s = rate(() => sign(hash, input, { key: keys.privateKey, ...opts }));
const v = rate(() => verify(hash, input, { key: keys.publicKey, ...opts }, signature));
console.log(`${name.padEnd(8)} sign ${String(s).padStart(6)}/s verify ${String(v).padStart(6)}/s`);
}
Поддержка в библиотеках: результаты тестов
Каждая библиотека проверяла одни и те же три токена: токен ES256, токен Ed25519, помеченный как EdDSA, и токен того же ключа, помеченный как Ed25519. В каждом вызове был разрешён только собственный алгоритм токена.
| Библиотека | ES256 | EdDSA | Ed25519 |
|---|---|---|---|
| jose 6.2.13 (Node.js) | да | да | да |
| jsonwebtoken 9.0.3 (Node.js) | да | нет: Unknown key type "ed25519". | нет |
| PyJWT 2.15.1 | да | да | нет: Algorithm not supported |
| joserfc 1.7.7 (Python) | да | да, с предупреждением EdDSA is deprecated via RFC 9864 | да |
| golang-jwt v5.3.1 | да | да | нет: signing method (alg) is unavailable |
| go-jose v4.1.6 | да | да | нет: error in cryptographic primitive |
| jwx v3.3.0 (Go) | да | да | да |
| Nimbus JOSE+JWT 10.10 (Java) | да | да, нужна необязательная зависимость Tink | да |
| JJWT 0.13.0 (Java, JDK 25) | да | да | нет: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'. |
| Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10) | да | нет: IDX10511: Signature validation failed. | нет |
| firebase/php-jwt 7.2.1 (PHP 8.5, sodium) | да | да | нет: Algorithm not supported |
| ruby-jwt 3.3.0 с jwt-eddsa 1.0.0 | да | да; без расширения: Key type OKP not supported | да |
jsonwebtoken 11.1.0 (Rust, rust_crypto) | да | да | нет: InvalidAlgorithmName |
ES256 заработал в 13 из 13 библиотек, EdDSA — в 11, Ed25519 — в 5. Два пробела в поддержке, которые чаще всего и решают вопрос, приходятся на самые распространённые стеки: в npm-пакете jsonwebtoken EdDSA нет вообще, а Microsoft.IdentityModel, на которую опирается аутентификация JWT Bearer в ASP.NET Core, для JWK с ключом Ed25519 сообщает IsSupportedAlgorithm: False. В .NET сбой выглядит как общее сообщение IDX10511, из-за которого ищут неверный ключ, а не отсутствующий алгоритм. Nimbus без Tink в classpath падает во время выполнения с ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify.
В браузерах Ed25519 в Web Cryptography API появился в Firefox 129, Safari 17 и Chrome 137 (примечания к выпуску Chrome 137, май 2025 года), поэтому для подписи в браузере, например доказательств DPoP, теперь можно использовать любую из двух кривых.
Поддержка в облачных KMS и HSM
Если ключ подписи хранится в управляемом сервисе ключей, выбор за вас делает сервис. По документации каждого поставщика на октябрь 2026 года:
| Сервис | P-256 (ES256) | Ed25519 |
|---|---|---|
| AWS KMS | ECC_NIST_P256, ECDSA_SHA_256 | ECC_NIST_EDWARDS25519, ED25519_SHA_512, с ноября 2025 года |
| Google Cloud KMS | EC_SIGN_P256_SHA256 на всех уровнях защиты, включая HSM | EC_SIGN_ED25519, только на программном уровне защиты |
| Azure Key Vault и Managed HSM | P-256 (EC и EC-HSM) | не предлагается |
| AWS CloudHSM | да | hsm2m.medium, и только в режиме без FIPS |
Источники: справочник спецификаций ключей AWS KMS, операция Sign в API AWS KMS, алгоритмы Google Cloud KMS, ключи Azure Key Vault, ключи Managed HSM, выпуски клиентского SDK AWS CloudHSM.
Две практические детали:
- Кодировка. AWS KMS и Google Cloud KMS возвращают подписи ECDSA в кодировке DER. JWS нужен формат raw
r ‖ sдлиной 64 байта (RFC 7518, § 3.4), поэтому подписант ES256 на базе KMS должен преобразовывать подпись. Забытое преобразование — классическая причина ошибок неверной подписи, например ошибки jsonwebtoken ES256 signatures must be 64 bytes; преобразование разобрано в статье Форматы подписей ECDSA, а конвертер DER в raw выполняет его для отдельной подписи. - Размер сообщения. Ed25519 в AWS KMS требует
MessageType: RAW, аSignпринимает не больше 4096 байт. Входные данные для подписи JWS — это закодированные заголовок и полезная нагрузка, поэтому токен с объёмными утверждениями нельзя подписать ключом Ed25519 в KMS. У ECDSA такого ограничения нет: можно отправить дайджест SHA-256 сMessageType: DIGEST.
Что требуют стандарты и поставщики
- OpenID Connect требует от поставщиков поддерживать RS256 для ID-токенов, за одним узким исключением (OIDC Core § 15.1). Ни одна из кривых этого не отменяет: поставщики, которые предлагают ES256 или EdDSA, делают это в дополнение к RS256.
- FAPI 2.0 Security Profile (финальная версия, февраль 2025 года, § 5.4.1) разрешает для JWT только
PS256,ES256иEdDSAс Ed25519 и указывает, что полностью определённый идентификатор Ed25519 будет разрешён, как только его зарегистрируют; с тех пор это сделано в RFC 9864. - Новая редакция рекомендаций по JWT (draft-ietf-oauth-rfc8725bis, в очереди RFC Editor) предписывает новым развёртываниям отдавать предпочтение полностью определённым именам из RFC 9864.
Ни одна из кривых не постквантовая. У ML-DSA теперь есть идентификаторы JOSE (RFC 9964, май 2026 года), но для токенов, которые живут минуты, вопрос в том, когда проверяющим сторонам придётся отвергать классические подписи, а не в том, можно ли будет позже прочитать сегодняшние токены. Именно возможность настраивать алгоритм для каждого ключа, как в следующем разделе, и делает такой переход осуществимым.
Выбор и последующая смена
| Ситуация | Выбор |
|---|---|
Токены проверяются третьими сторонами, сервисами на .NET или npm-пакетом jsonwebtoken | ES256 |
| Ключ подписи в Azure Key Vault, Managed HSM, Google Cloud HSM или HSM в режиме FIPS | ES256 |
| Все проверяющие стороны под вашим контролем, ключ хранится программно или в AWS KMS | Ed25519 |
| API или профиль перечисляет только определённые алгоритмы (FAPI 2.0, спецификация партнёра) | один из перечисленных; чаще всего в списке есть ES256 |
Если вы выбираете Ed25519 сегодня, указывайте в токенах "alg": "EdDSA". Отказ от этого имени в RFC 9864 сформулирован как SHOULD, а такое требование допускает задокументированные эксплуатационные препятствия; проверяющая сторона, которая отвергает новое имя, — как раз такое препятствие. На стороне проверки принимайте оба имени, причём только с ключом OKP, у которого crv равен Ed25519, чтобы устаревшее имя никогда не выбрало кривую, которую вы не предполагали. С jose:
const { payload } = await jwtVerify(token, ed25519PublicKey, {
algorithms: ["Ed25519", "EdDSA"],
issuer: "https://id.example.com",
audience: "https://api.example.com",
});
На токенах из этой статьи (jose 6.2.13) этот код принял и токен EdDSA, и токен Ed25519, а токен ES256 отверг с ошибкой "alg" (Algorithm) Header Parameter value not allowed. Последующая смена алгоритма — это ротация ключей: опубликуйте новый ключ с собственными kid и alg рядом со старым, начните подписывать им, а старый ключ удалите после того, как истечёт срок действия последнего старого токена. Сроки подробно разобраны в статье Ротация ключей JWKS без простоя, а в статье Ошибки при проверке JWT — закрепление алгоритма за ключом, чтобы токен никогда не выбирал его сам.
Источники
- RFC 7518 § 3.4: ECDSA в JWS
- RFC 8032: EdDSA, § 5.1.6 (подпись) и § 5.1.7 (проверка,
S < L) - RFC 8037: EdDSA и ключи OKP в JOSE
- RFC 9864: полностью определённые алгоритмы для JOSE и COSE
- Реестр алгоритмов JOSE в IANA и реестр алгоритмов COSE
- RFC 6979: детерминированный ECDSA
- FIPS 186-5: стандарт цифровой подписи (Digital Signature Standard), включая EdDSA
- FAPI 2.0 Security Profile, § 5.4.1
- OpenID Connect Core 1.0, § 15.1
- Документация поставщиков, ссылки на которую приведены в разделе о KMS