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

ES256 или EdDSA для JWT: что выбрать в 2026 году

Обе схемы дают 64-байтные подписи при стойкости 128 бит. Различаются они тем, как ломаются, где могут храниться ключи и какие библиотеки умеют их читать. Всё, что утверждается ниже, проверено на актуальных версиях библиотек или сверено с документацией поставщика в октябре 2026 года.

Коротко. Подписывайте ES256, если токены уходят из-под вашего контроля: его проверяет каждая протестированная нами библиотека JOSE, любой крупный облачный KMS и HSM может хранить его ключ, а стандарты вроде FAPI 2.0 включают его в свои списки. Выбирайте Ed25519, если все проверяющие стороны под вашим контролем и вы можете хранить ключ программно или в AWS KMS: его подписи детерминированы, и их нельзя превратить во вторую действительную форму, а это исключает два класса ошибок ECDSA. Если вы выбрали Ed25519, пока публикуйте его как "alg": "EdDSA", а на стороне проверки принимайте и EdDSA, и новое имя Ed25519. Меньше половины протестированных нами библиотек пока понимают новое имя.

Все токены и ключи на этой странице сгенерированы специально для неё. Результаты по библиотекам получены запусками в официальных образах Docker 20 октября 2026 года; версии указаны рядом с каждым результатом.

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

ES256EdDSA / Ed25519
СхемаECDSA на P-256 с SHA-256EdDSA на Edwards25519 (RFC 8032, § 5.1)
Определение в JOSERFC 7518 § 3.4RFC 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. Различаются только строка заголовка и открытый ключ:

Вывод: одни и те же утверждения (claims), подписанные тремя способами (jose 6.2.13, Node.js 24.22)
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 не даёт второй действительной подписи. Проверяем оба утверждения на практике:

props.mjs: детерминизм и пластичность (jose 6.2.13, jsonwebtoken 9.0.3)
// 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");
Вывод (node:24.22.0-bookworm-slim)
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.864 840/с23 163/с60 249/с23 398/с
Стандартная библиотека Go 1.27.353 877/с25 778/с75 218/с35 639/с

С OpenSSL оба алгоритма отличаются друг от друга в пределах нескольких процентов; в Go Ed25519 работает примерно на 40 % быстрее, чем P-256. В любом случае одно ядро проверяет больше двадцати тысяч токенов в секунду — гораздо больше, чем пропустят сеть и разбор JSON вокруг этой проверки. На вашем оборудовании абсолютные значения будут другими. Замер в Node:

bench.mjs (node:24.22.0-bookworm-slim)
// 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. В каждом вызове был разрешён только собственный алгоритм токена.

БиблиотекаES256EdDSAEd25519
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 KMSECC_NIST_P256, ECDSA_SHA_256ECC_NIST_EDWARDS25519, ED25519_SHA_512, с ноября 2025 года
Google Cloud KMSEC_SIGN_P256_SHA256 на всех уровнях защиты, включая HSMEC_SIGN_ED25519, только на программном уровне защиты
Azure Key Vault и Managed HSMP-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-пакетом jsonwebtokenES256
Ключ подписи в Azure Key Vault, Managed HSM, Google Cloud HSM или HSM в режиме FIPSES256
Все проверяющие стороны под вашим контролем, ключ хранится программно или в AWS KMSEd25519
API или профиль перечисляет только определённые алгоритмы (FAPI 2.0, спецификация партнёра)один из перечисленных; чаще всего в списке есть ES256

Если вы выбираете Ed25519 сегодня, указывайте в токенах "alg": "EdDSA". Отказ от этого имени в RFC 9864 сформулирован как SHOULD, а такое требование допускает задокументированные эксплуатационные препятствия; проверяющая сторона, которая отвергает новое имя, — как раз такое препятствие. На стороне проверки принимайте оба имени, причём только с ключом OKP, у которого crv равен Ed25519, чтобы устаревшее имя никогда не выбрало кривую, которую вы не предполагали. С jose:

Принимать оба имени Ed25519, только для одного ключа
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 — закрепление алгоритма за ключом, чтобы токен никогда не выбирал его сам.

Источники