跳到主要内容
ecdsa.com

JWS vs JWE:JOSE 令牌该签名还是加密

JWS 能证明载荷由谁创建,但任何人都能读取它。JWE 对接收方以外的所有人隐藏载荷,但在使用公钥算法时,它无法说明载荷由谁创建。

选 JWS 还是 JWE,取决于 JOSE 令牌要保护什么。JSON Web Signature(RFC 7515)为仍然可读的载荷提供完整性和真实性;JSON Web Encryption(RFC 7516)则提供机密性。你遇到的 JWT 几乎都是 JWS,这常常让以为声明是保密的人感到意外。

概览

JWSJWE
规范RFC 7515RFC 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 运行,用两种方式保护同一组声明。

jose:同一组声明分别作为 JWS 和 JWE
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 中完成标准化后,改用这些格式。

迁移说明

  1. 对现有 JWS 令牌中的声明分类:凡是不能让持有者看到的内容,要么移出令牌,要么移入 JWE。
  2. 逐步引入嵌套令牌:向能够解密的客户端签发 cty: JWT 的 JWE,并保持内层 JWS 不变,以便复用验证逻辑。
  3. 把加密密钥与签名密钥分开分发:JWKS 条目应带有 use: enc 或 use: sig,以及各不相同的 kid 值。
  4. 在两端固定算法,并拒绝 RSA1_5、不设上限的 PBES2 和压缩。
  5. 重新检查日志记录:令牌加密后,要确保解密后的声明不会被记录到令牌本身从未出现过的地方。JWE 查看器无需解密即可显示 JWE 的头部和结构。

参考资料

全部对比