跳到主要内容
ecdsa.com
← 全部文章

JWT 选 ES256 还是 EdDSA:2026年选型建议

两者都在 128 位安全强度下生成 64 字节的签名。区别在于它们出错的方式、密钥可以存放在哪里,以及哪些库能读取它们。下文的每一条结论都在2026年10月针对当前的库版本做过测试,或对照提供方的文档核实过。

简短回答:如果令牌会离开你的控制范围,就用 ES256 签名:我们测试过的每个 JOSE 库都能验证它,各大云 KMS 和 HSM 都能存放这种密钥,FAPI 2.0 等标准也列出了它。如果你控制所有验证方,并且可以把密钥放在软件中或 AWS KMS 里,就选 Ed25519:它的签名是确定性的,也无法被改造成另一种有效形式,从而消除了两类 ECDSA 错误。如果选择 Ed25519,目前先以 "alg": "EdDSA" 发布,并在验证端同时接受 EdDSA 和新名称 Ed25519。在我们测试的库中,能识别新名称的还不到一半。

本页的所有令牌和密钥都是专门为本文生成的。库的测试结果来自2026年10月20日在官方 Docker 镜像中的运行,每条结果都标注了所用版本。

两个名称的含义

ES256EdDSA / Ed25519
方案ECDSA,曲线 P-256,哈希 SHA-256EdDSA,基于 Edwards25519(RFC 8032 §5.1)
JOSE 定义RFC 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年10月)弃用了这个多态名称,并注册了完全指定的 Ed25519 和 Ed448。密钥格式不变,变的只是 alg 字符串。ES256 在 JOSE 中本来就是完全指定的,无需更名。在 COSE 中,RFC 9864 对 ECDSA 做了同样的处理:ES256(-7)被弃用,改用 ESP256(-9)。这一点影响的是 WebAuthn 和基于 COSE 的凭证,而不是 JWT。

大小:关键之处完全相同

两种签名都是 64 字节,即 86 个 base64url 字符。唯一的差别在于头部字符串和公钥:

输出:同一组声明的三种签名方式(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 每次签名都需要一个全新的秘密数。只要重复使用一次,或者让它稍微可预测,就能从公开的签名中算出私钥;ECDSA nonce 重用一文给出了其中的代数推导和真实事件。大多数 JOSE 库从系统随机数生成器获取这个数,因此相同的输入每次得到的签名都不同。RFC 6979 定义了一种确定性的派生方法,但你的库、HSM 或 KMS 是否采用它,不在你的控制范围之内。

EdDSA 在设计上就从私钥和消息派生每次签名使用的值(RFC 8032 §5.1.6)。密钥相同、消息相同,签名就相同,整个过程不涉及随机数生成器。这消除了 PlayStation 3 密钥泄露和2013年的 Android 钱包密钥泄露背后的那种失效模式。但它并不能消除所有风险:在攻击者能接触到的硬件上,确定性签名更容易受到故障注入的影响,这也是一些较新的设计重新加入随机性的原因。对于运行在服务器上的 JWT 颁发者来说,确定性显然是优势。

延展性:轻易得到第二个有效令牌

ECDSA 签名 (r, s) 有一个孪生签名:(r, n − s) 用同一个密钥对同一条消息同样能验证通过,其中 n 是曲线的阶。JOSE 库不会拒绝这个孪生签名。RFC 8032 规定的 Ed25519 要求验证方拒绝任何不小于群的阶 L 的 S,因此给 S 加上 L 不会产生第二个有效签名。下面对这两点做了实测:

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 规范化不属于 JOSE,我们测试的库也都没有应用它。

速度:不是决定性因素

常见的“Ed25519 快一倍”的说法取决于具体实现。在同一台机器上(arm64,单核,Docker 内),对一个 JWT 签名输入做签名和验证:

实现ES256 签名ES256 验证Ed25519 签名Ed25519 验证
Node.js 24.22,OpenSSL 3.5.864,840/s23,163/s60,249/s23,398/s
Go 1.27.3 标准库53,877/s25,778/s75,218/s35,639/s

在 OpenSSL 下,两者相差不过几个百分点;Go 的 Ed25519 比它的 P-256 快约 40%。无论哪种情况,单核每秒都能验证两万多个令牌,远远超过周边的网络和 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 令牌,一个标为 EdDSA 的 Ed25519 令牌,以及同一密钥签名、标为 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

在 13 个库中,ES256 13 个都能用,EdDSA 11 个,Ed25519 只有 5 个。最常左右这个选择的两处缺口,恰恰出现在最常用的技术栈中:npm 包 jsonwebtoken 完全不支持 EdDSA;ASP.NET Core 的 JWT Bearer 身份验证所用的 Microsoft.IdentityModel 对 Ed25519 JWK 报告 IsSupportedAlgorithm: False。.NET 报出的是笼统的 IDX10511 消息,会让人去排查是不是密钥用错了,而想不到是缺少算法支持。如果类路径上没有 Tink,Nimbus 会在运行时报错:ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify。

在浏览器中,Web Cryptography API 的 Ed25519 支持已在 Firefox 129、Safari 17 和 Chrome 137 中发布(Chrome 137 版本说明,2025年5月),因此浏览器内的签名(例如 DPoP 证明的签名)现在两种曲线都可以用。

云 KMS 与 HSM 的支持情况

如果签名密钥存放在托管密钥服务中,就由服务替你做决定。以下依据各提供方2026年10月的文档:

服务P-256(ES256)Ed25519
AWS KMSECC_NIST_P256、ECDSA_SHA_256ECC_NIST_EDWARDS25519、ED25519_SHA_512,自2025年11月起
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 密钥规格参考、AWS KMS Sign API、Google Cloud KMS 算法、Azure Key Vault 密钥、Managed HSM 密钥、AWS CloudHSM 客户端 SDK 版本。

实践中有两个细节:

  • 编码:AWS KMS 和 Google Cloud KMS 返回的 ECDSA 签名是 DER 编码的。JWS 需要 64 字节的原始 r ‖ s 格式(RFC 7518 §3.4),因此基于 KMS 的 ES256 签名器必须做转换。忘记转换是签名无效错误的典型原因,例如 jsonwebtoken 的 ES256 signatures must be 64 bytes;ECDSA 签名格式一文讲解了转换方法,DER ⇄ 原始格式转换器则可以转换单个签名。
  • 消息大小:AWS KMS 中的 Ed25519 要求 MessageType: RAW,而 Sign 最多接受 4096 字节。JWS 签名输入是编码后的头部和载荷,因此声明较大的令牌无法在 KMS 中用 Ed25519 签名。ECDSA 没有这个限制,因为可以用 MessageType: DIGEST 发送 SHA-256 摘要。

标准和提供方的要求

  • OpenID Connect 要求提供方为 ID 令牌支持 RS256,只有一个很窄的例外(OIDC Core §15.1)。这两种曲线都不能取而代之;提供 ES256 或 EdDSA 的提供方都是在此之外额外提供。
  • FAPI 2.0 安全规范(Security Profile,正式版,2025年2月,§5.4.1)对 JWT 只允许 PS256、ES256 和使用 Ed25519 的 EdDSA,并表示完全指定的 Ed25519 标识符一经注册也将被允许,而 RFC 9864 此后已经完成了注册。
  • JWT 最佳实践修订版(draft-ietf-oauth-rfc8725bis,已进入 RFC Editor 队列)要求新的部署优先采用 RFC 9864 中完全指定的名称。

这两种曲线都不是后量子算法。ML-DSA 现在已有 JOSE 标识符(RFC 9964,2026年5月),但对于只存活几分钟的令牌,问题在于验证方何时需要拒绝经典签名,而不是今天的令牌日后能否被读取。像下一节那样让算法可以按密钥配置,正是这种切换得以实现的前提。

如何选择,以及日后如何切换

场景选择
令牌由第三方、.NET 服务或 npm 包 jsonwebtoken 验证ES256
签名密钥位于 Azure Key Vault、Managed HSM、Google Cloud HSM 或 FIPS 模式的 HSM 中ES256
你控制所有验证方,密钥放在软件中或 AWS KMS 里Ed25519
API 或规范只列出特定算法(FAPI 2.0、合作方规范)按它列出的选;ES256 是最常出现的那个

如果你现在选择 Ed25519,就输出 "alg": "EdDSA"。RFC 9864 的弃用是一条应当(SHOULD)级别的要求,遇到有文档记载的运维障碍时允许例外,而拒绝新名称的验证方正是这样一种障碍。在验证端同时接受两个名称,但只配合 crv 为 Ed25519 的 OKP 密钥使用,这样已弃用的名称就永远不会选中你不想用的曲线。使用 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 验证常见错误则介绍了如何按密钥固定算法,让令牌永远无法自行选择算法。

来源