选 JWS 还是 JWE,取决于 JOSE 令牌要保护什么。JSON Web Signature(RFC 7515)为仍然可读的载荷提供完整性和真实性;JSON Web Encryption(RFC 7516)则提供机密性。你遇到的 JWT 几乎都是 JWS,这常常让以为声明是保密的人感到意外。
概览
| JWS | JWE | |
|---|---|---|
| 规范 | RFC 7515 | RFC 7516 |
| 保护什么 | 完整性和来源 | 机密性(以及借助 AEAD 实现的完整性) |
| 紧凑格式 | header.payload.signature(3 个部分) | header.encrypted_key.iv.ciphertext.tag(5 个部分) |
| 头部中的算法 | alg(例如 ES256、RS256、HS256、Ed25519) | alg 用于密钥管理(ECDH-ES、ECDH-ES+A256KW、RSA-OAEP-256、A256KW、dir),另加 enc 用于内容加密(A256GCM、A128CBC-HS256) |
| 载荷是否可见 | 是,只做了 base64url 编码 | 否 |
| 能否验证发送方身份 | 能(签名或 MAC) | 只有使用对称密钥或经过身份验证的密钥协商时才能;单独使用 ECDH-ES 或 RSA-OAEP 时不能 |
| 70 字节声明集的大小(实测见下文) | 186 个字符(ES256) | 405 个字符(ECDH-ES+A256KW 配合 A256GCM) |
| 典型用途 | JWT 访问令牌和 ID 令牌、DPoP、签名请求、SD-JWT | 加密的 ID 令牌、存储在客户端的令牌、部分 FAPI 和 EUDI 响应 |
两者各自保护什么
JWS 对 base64url(header) + "." + base64url(payload) 签名。任何持有令牌的人都能解码并读取其中的声明;JWT 调试器做的正是这件事。签名证明令牌来自密钥持有者且未被改动,而 API 要信任这些声明,需要的正是这一点。
JWE 生成一个随机的内容加密密钥(CEK),用 enc 指定的 AEAD 加密载荷,再用 alg 指定的密钥管理算法把 CEK 交给接收方:用 RSA-OAEP 包装;在 ECDH-ES 密钥协商之后用 AES 密钥包装算法包装;直接从 ECDH-ES 派生;或者预先共享(dir)。受保护头部作为关联数据参与认证。只有接收方能读取载荷。
关键的不对称在于:使用公钥方式的密钥管理时,任何拥有接收方公钥的人都能生成有效的 JWE。解密成功并不能说明发送方是谁。因此,既敏感又必须可信的令牌要采用嵌套结构:把签名的 JWT(JWS)放进带 cty: JWT 的 JWE 中,解密后再验证。
实际大小
下面的脚本在 Node.js 26.10 上用 jose 6.2.13 运行,用两种方式保护同一组声明。
import { CompactEncrypt, CompactSign, compactDecrypt, compactVerify, generateKeyPair } from "jose";
const claims = new TextEncoder().encode(JSON.stringify({ sub: "user-42", email: "alice@example.com", plan: "pro" }));
// JWS: integrity and origin; the payload is only base64url-encoded.
const signer = await generateKeyPair("ES256");
const jws = await new CompactSign(claims).setProtectedHeader({ alg: "ES256" }).sign(signer.privateKey);
await compactVerify(jws, signer.publicKey);
// JWE: confidentiality for the holder of the recipient's private key.
const recipient = await generateKeyPair("ECDH-ES+A256KW");
const jwe = await new CompactEncrypt(claims).setProtectedHeader({ alg: "ECDH-ES+A256KW", enc: "A256GCM" }).encrypt(recipient.publicKey);
const { plaintext } = await compactDecrypt(jwe, recipient.privateKey);
console.log("JWS parts:", jws.split(".").length, "length:", jws.length, "payload readable:", Buffer.from(jws.split(".")[1], "base64url").toString().includes("alice"));
console.log("JWE parts:", jwe.split(".").length, "length:", jwe.length, "decrypts:", new TextDecoder().decode(plaintext).includes("alice"));
JWS parts: 3 length: 186 payload readable: true
JWE parts: 5 length: 405 decrypts: true
JWE 的额外开销主要来自头部中的临时公钥(epk)、被包装的密钥、12 字节的 IV 和 16 字节的认证标签。先签名后加密的嵌套令牌还会更大,因为整个 JWS 都会成为 JWE 的明文。
常见陷阱
- 把 JWS 声明当作机密:不要把个人数据、你不愿公开的内部标识符或机密信息放进只签名的令牌;这些内容最终会出现在日志、浏览器存储和技术支持工单中。
- 信任未签名的 JWE:用 ECDH-ES 或 RSA-OAEP 解密成功,只能证明令牌是加密给你的。
- 脆弱或过时的 JWE 算法:
RSA1_5易受填充预言攻击,带有超大p2c迭代次数的PBES2可被用于拒绝服务,而接受曲线外点的ECDH-ES实现曾在2017年泄露私钥(RFC 8725 §2.5)。优先使用ECDH-ES+A256KW或RSA-OAEP-256配合A256GCM;如果必须支持 PBES2,要限制其迭代次数。 - 由令牌决定算法:与 JWS 一样,接收方对每个密钥只应接受它预期的
alg和enc值。 - 压缩:JWE 的
zip: DEF会在加密前压缩数据,可能通过长度泄露明文的信息(RFC 8725 §3.6);应避免使用。
如何选择
- 由你的 API 检查的访问令牌:JWS;声明尽量精简,如果声明不能被读取,就改用不透明令牌配合内省。
- 含个人数据、由浏览器或移动应用存储的 ID 令牌:通常用 JWS;如果你的威胁模型包括客户端环境,就用嵌套 JWE。
- 经过不受信任的中间方传递的令牌(经第三方的重定向、嵌入 URL 或 QR 码的令牌):把 JWS 嵌套在 JWE 中。
- 通过双向认证 TLS 传输的机器对机器消息:通常两者都不需要;TLS 已经在传输过程中提供了机密性和完整性,只有当消息在存储或转发之后仍须可验证时,JWS 才有额外价值。
- 发给已知接收方的端到端加密载荷:使用
ECDH-ES+A256KW和A256GCM的 JWE,或者等基于 HPKE 的格式在 JOSE 中完成标准化后,改用这些格式。
迁移说明
- 对现有 JWS 令牌中的声明分类:凡是不能让持有者看到的内容,要么移出令牌,要么移入 JWE。
- 逐步引入嵌套令牌:向能够解密的客户端签发
cty: JWT的 JWE,并保持内层 JWS 不变,以便复用验证逻辑。 - 把加密密钥与签名密钥分开分发:JWKS 条目应带有
use: enc或use: sig,以及各不相同的kid值。 - 在两端固定算法,并拒绝
RSA1_5、不设上限的PBES2和压缩。 - 重新检查日志记录:令牌加密后,要确保解密后的声明不会被记录到令牌本身从未出现过的地方。JWE 查看器无需解密即可显示 JWE 的头部和结构。