alg 名称的真正含义
JWT 就是载荷为 JSON 的 JWS(JSON Web Signature):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 字节(原始 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 使用与 RS256 相同的 RSA 密钥材料,只是换成了较新的 PSS 填充;如果你的生态支持,PSS 是更好的 RSA 模式。(JWT 之外更深入的算法比较,参见 ECDSA 与 RSA 对比。)
令牌大小:64 字节与 256 字节的签名
ES256 签名是 64 个原始字节——约 86 个 base64url 字符。使用常见 2048 位密钥的 RS256 签名是 256 字节——约 342 个字符——使用 RSA-3072 时则是 384 字节。由于两种情况下头部和载荷都一样,从 RS256 切换到 ES256 通常能让每个令牌减少 ~250 字节。当令牌放在 Cookie(4 KB 上限)中、放在每个请求的 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 的又一个理由。 - 显式校验声明——颁发者、受众、过期时间——在同一次调用中完成。
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 签名是原始 r‖s,而不是 DER
从数学上说,ECDSA 签名是一对整数 (r, s),而这对整数有两种常见的字节编码。RFC 7518 §3.4 明确规定:在 JWS 中,ES256 签名是把 r 和 s 作为两个 32 字节的大端序整数拼接起来——恰好 64 字节,再做 base64url 编码。它并不是 OpenSSL、Java 的 Signature 和 Node 的 crypto.sign() 默认生成的 ASN.1 DER SEQUENCE。
这是手写 JWT 代码中出现“invalid signature”的最常见原因之一:令牌里装的是 ~70 字节的 DER 数据(或者验证方把 64 字节的原始签名交给了按 DER 解析的 API),于是即使密钥、消息和算法全都正确,验证仍然失败。如果用 Node 内置的 crypto 签名,直接要求输出 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 ⇄ 原始格式转换器可以双向转换,并显示 r 和 s 的值;字节层面的细节以及 high-S 这一微妙之处,见 ECDSA 签名格式:DER 与原始格式。要检查真实的令牌——头部、声明,以及用你的公钥做 ES256 验证——把它粘贴到 JWT 调试器中即可。
如何选择,以及如何无中断地迁移
把现有的 RS256 部署迁移到 ES256 是一次密钥轮换,而不需要所有相关方在同一天一起切换:
- 在 JWKS 中把新的 EC 密钥与 RSA 密钥一起发布,两者都带上正确的
kid和alg值,并给使用方留出刷新 JWKS 缓存的时间。 - 开始用新的
kid签发 ES256 令牌。按kid选择密钥并对每个密钥强制指定算法的验证方,会继续接受新旧两代令牌,每个令牌都只对照唯一的一个密钥验证。 - 等所有 RS256 令牌都过期后,从 JWKS 中移除 RSA 密钥,并把验证方的允许列表收缩回
["ES256"]。
只需记住一句话:选择算法的是验证方,而不是令牌。做对这一点,ES256 与 RS256 之间的抉择就回到了它应有的样子——一个关于字节和毫秒、影响不大的工程权衡。