大多数开发者只知道 JWT 的签名(JWS),却不知道 JWT 还有一个更强大的兄弟——JWE(JSON Web Encryption)。当你的 API Token 中携带了用户角色、权限范围、个人数据等敏感信息时,签名只能防篡改,任何人都能 Base64 解码读取 payload 内容。JWE 解决了这个问题:它对 JWT 的 payload 进行真正的加密,即使 Token 被截获,攻击者也无法获取其中的数据。根据 Auth0 2025 年的安全报告,超过 40% 的 JWT 实现存在敏感信息明文暴露风险,而 JWE 正是 JOSE(JSON Object Signing and Encryption)规范家族中专门解决这一问题的核心标准。
🔐 一、JWE 核心原理与 JWS 对比
1.1 为什么签名不够?JWE 的必要性
先看一个真实场景:你的 JWT payload 中包含这样的数据:
{
"sub": "user_12345",
"role": "admin",
"email": "ceo@company.com",
"permissions": ["delete:all", "billing:manage"],
"internal_note": "VIP customer, special pricing"
}
使用 JWS 签名后,Token 长这样:eyJhbGciOi...。任何人拿到这个 Token,只需 JSON.parse(atob(token.split('.')[1])) 就能读取全部内容——签名只保证不被篡改,不保证不被读取。
⚠️ **警告:**永远不要在 JWS(普通 JWT)的 payload 中存放敏感信息。JWS 的 Base64Url 编码是可逆的,它不是加密!
JWE 通过加密解决了这个问题。下面是 JWS 和 JWE 的核心对比:
| 特性 | JWS (签名) | JWE (加密) |
|---|---|---|
| 目标 | 防篡改、验证来源 | 防篡改 + 防读取 |
| Payload 可读性 | ❌ 任何人可读 | ✅ 仅持有密钥者可读 |
| Token 结构 | 3 段 (header.payload.signature) | 5 段 (header.key.iv.ciphertext.tag) |
| 性能开销 | 低(签名验证) | 中等(加密 + 解密) |
| 典型场景 | 普通 API 认证 | 含敏感数据的 Token |
| 常见算法 | RS256, ES256, HS256 | RSA-OAEP, A256GCM, ECDH-ES |
1.2 JWE 的五段式结构
JWE 由 5 个 Base64Url 编码的部分组成,以 . 分隔:
BASE64URL(Header) . BASE64URL(EncryptedKey) . BASE64URL(IV) . BASE64URL(Ciphertext) . BASE64URL(AuthTag)
每一段的作用:
| 部分 | 内容 | 说明 |
|---|---|---|
| Header | 算法与加密方式 | 包含 alg(密钥加密算法)和 enc(内容加密算法) |
| EncryptedKey | 加密后的 CEK | 用接收方公钥加密的内容加密密钥 |
| IV | 初始化向量 | 随机生成,确保相同明文产生不同密文 |
| Ciphertext | 加密后的 payload | 实际数据的加密结果 |
| AuthTag | 认证标签 | 验证密文完整性的 MAC 值 |
📌 **记住:**JWE 采用「信封加密」模式——用对称算法(如 AES)加密数据(快),再用非对称算法(如 RSA)加密对称密钥(安全分发)。这兼顾了性能和安全性。
🔧 二、JWE 加密算法深度对比
2.1 密钥管理算法(Key Management Algorithm)
密钥管理算法决定了如何加密「内容加密密钥(CEK)」。选择哪种算法取决于你的架构:
| 算法 | 类型 | 密钥长度 | 适用场景 | 性能 |
|---|---|---|---|---|
| RSA-OAEP | 非对称 | 2048+ bit | 单向加密,服务端持有私钥 | ⚠️ 较慢 |
| RSA-OAEP-256 | 非对称 | 2048+ bit | 比 RSA-OAEP 更安全的哈希 | ⚠️ 较慢 |
| A128KW | 对称 | 128 bit | 内部服务间通信 | ✅ 快 |
| A256KW | 对称 | 256 bit | 内部服务间通信(高安全) | ✅ 快 |
| ECDH-ES | 非对称 | P-256/P-384/P-521 | 前向保密,推荐 | ✅ 较快 |
| PBES2-HS256+A128KW | 密码派生 | 可变 | 基于密码的加密 | ⚠️ 较慢 |
| dir | 直接使用 | 可变 | 共享密钥场景 | ✅ 最快 |
💡 **提示:**如果你的场景是「服务端加密,客户端解密」(如 OpenID Connect),推荐 ECDH-ES——它提供前向保密(Forward Secrecy),即使长期密钥泄露,历史 Token 也不会被解密。
2.2 内容加密算法(Content Encryption Algorithm)
内容加密算法决定了如何加密实际的 payload 数据,使用 AES-GCM(认证加密)或 AES-CBC + HMAC(组合模式):
| 算法 | 密钥长度 | 模式 | 安全性 | 推荐 |
|---|---|---|---|---|
| A128GCM | 128 bit | AES-GCM | ✅ 高 | 一般场景 |
| A256GCM | 256 bit | AES-GCM | ✅✅ 很高 | ⭐ 推荐 |
| A128CBC-HS256 | 256 bit | AES-CBC + HMAC | ✅ 高 | 兼容旧系统 |
| A256CBC-HS384 | 384 bit | AES-CBC + HMAC | ✅✅ 很高 | 高安全需求 |
| A128CBC-HS512 | 512 bit | AES-CBC + HMAC | ✅✅ 很高 | 最高安全需求 |
⚡ **关键结论:**新项目首选 A256GCM——它是 AES-256 认证加密模式,同时提供机密性和完整性保护,性能优异,且是 NIST 推荐标准。
2.3 算法组合推荐
| 场景 | 推荐组合 | 说明 |
|---|---|---|
| Web API 认证 Token | RSA-OAEP-256 + A256GCM |
最通用,服务端加密,任意客户端解密 |
| OpenID Connect ID Token | ECDH-ES + A256GCM |
前向保密,OIDC 规范推荐 |
| 内部微服务 | A256KW + A256GCM |
对称加密最快,适合内部信任环境 |
| 基于密码的加密 | PBES2-HS256+A128KW + A256GCM |
用户密码派生密钥 |
| 最高安全需求 | ECDH-ES+A256KW + A256GCM |
混合模式,兼顾前向保密与性能 |
🚀 三、Node.js 完整实现
3.1 使用 jose 库(推荐)
jose 是 JOSE 规范最完整的 JavaScript/TypeScript 实现,支持 JWS、JWE、JWK、JWKS 全系列标准,零依赖、体积小、支持所有主流运行时。
# 安装 jose 库
npm install jose
RSA-OAEP + A256GCM 加密与解密:
// RSA-OAEP + A256GCM 完整加密解密示例
import * as jose from 'jose'
// 1. 生成 RSA 密钥对
const { publicKey, privateKey } = await jose.generateKeyPair('RSA-OAEP-256', {
extractable: true,
})
// 2. 准备敏感 payload
const payload = {
sub: 'user_12345',
role: 'admin',
email: 'ceo@company.com',
permissions: ['read:users', 'write:billing'],
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
}
// 3. 加密为 JWE Token
const jweToken = await new jose.EncryptJWT(payload)
.setProtectedHeader({
alg: 'RSA-OAEP-256', // 密钥加密算法
enc: 'A256GCM', // 内容加密算法
typ: 'JWT',
})
.setIssuedAt()
.setIssuer('https://api.example.com')
.setAudience('https://app.example.com')
.setExpirationTime('1h')
.encrypt(publicKey)
console.log('JWE Token:', jweToken)
// 输出: eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwidHlwIjoiSldUIn0.eyJ...
// 4. 解密 JWE Token
const { payload: decrypted, protectedHeader } = await jose.jwtDecrypt(
jweToken,
privateKey,
{
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
}
)
console.log('解密后的 payload:', decrypted)
// { sub: 'user_12345', role: 'admin', email: 'ceo@company.com', ... }
console.log('保护头:', protectedHeader)
// { alg: 'RSA-OAEP-256', enc: 'A256GCM', typ: 'JWT' }
ECDH-ES + A256GCM(前向保密):
// ECDH-ES 提供前向保密 — 即使长期密钥泄露,历史 Token 仍安全
import * as jose from 'jose'
// 1. 生成 EC 密钥对(P-256 曲线)
const { publicKey, privateKey } = await jose.generateKeyPair('ECDH-ES', {
crv: 'P-256',
extractable: true,
})
// 2. 加密
const token = await new jose.EncryptJWT({
sub: 'user_12345',
secret_data: 'this is highly confidential',
})
.setProtectedHeader({
alg: 'ECDH-ES',
enc: 'A256GCM',
})
.setExpirationTime('30m')
.encrypt(publicKey)
// 3. 解密
const { payload } = await jose.jwtDecrypt(token, privateKey)
console.log(payload.secret_data) // 'this is highly confidential'
⚠️ **警告:**ECDH-ES 每次加密都会生成临时密钥对(ephemeral key),这保证了前向保密——但这也意味着你不能用同一个密钥解密多个 Token 来做密钥缓存。如果你需要批量解密,考虑使用
ECDH-ES+A256KW(混合模式)。
3.2 对称加密(内部服务间通信)
// 对称加密 — 适用于内部服务间通信,双方共享同一密钥
import * as jose from 'jose'
// 1. 生成 256-bit 共享密钥(也可从环境变量加载)
const sharedSecret = await jose.generateSecret('A256GCM', { extractable: true })
// 2. 导出为 Base64Url 格式(用于存储/传输)
const secretB64 = await jose.exportJWK(sharedSecret)
console.log('密钥(存储到安全位置):', JSON.stringify(secretB64))
// 3. 加密
const token = await new jose.EncryptJWT({
service: 'user-service',
action: 'transfer-funds',
amount: 50000,
currency: 'CNY',
})
.setProtectedHeader({
alg: 'dir', // 直接使用共享密钥
enc: 'A256GCM',
})
.setExpirationTime('5m')
.encrypt(sharedSecret)
// 4. 解密
const { payload } = await jose.jwtDecrypt(token, sharedSecret)
console.log(payload) // { service: 'user-service', action: 'transfer-funds', ... }
💡 四、JWKS 密钥管理与多密钥轮换
4.1 JWKS(JSON Web Key Set)架构
生产环境中,你需要支持密钥轮换(Key Rotation)——定期更换密钥,即使某个密钥泄露,影响范围也有限。JWKS 是标准的密钥分发格式:
// JWKS 密钥管理完整实现
import * as jose from 'jose'
// 1. 生成多个密钥对(模拟密钥轮换)
const keyPairs = []
for (let i = 0; i < 3; i++) {
const { publicKey, privateKey } = await jose.generateKeyPair('RSA-OAEP-256', {
extractable: true,
})
// 导出为 JWK 格式
const pubJwk = await jose.exportJWK(publicKey)
const privJwk = await jose.exportJWK(privateKey)
// 添加 kid(Key ID)标识
const kid = `key-${Date.now()}-${i}`
pubJwk.kid = kid
pubJwk.alg = 'RSA-OAEP-256'
pubJwk.use = 'enc'
privJwk.kid = kid
keyPairs.push({ kid, publicKey, privateKey, pubJwk, privJwk })
}
// 2. 构建 JWKS(公钥集合,对外暴露)
const jwks = {
keys: keyPairs.map(k => k.pubJwk),
}
console.log('JWKS 端点响应:', JSON.stringify(jwks, null, 2))
// 3. 使用最新密钥加密
const latestKey = keyPairs[keyPairs.length - 1]
const token = await new jose.EncryptJWT({ sub: 'user_789', role: 'user' })
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
kid: latestKey.kid, // 指定用于加密的密钥 ID
})
.setExpirationTime('1h')
.encrypt(latestKey.publicKey)
// 4. 解密时通过 kid 查找对应密钥
const protectedHeader = jose.decodeProtectedHeader(token)
const matchingKey = keyPairs.find(k => k.kid === protectedHeader.kid)
if (!matchingKey) {
throw new Error(`未找到 kid=${protectedHeader.kid} 对应的密钥`)
}
const { payload } = await jose.jwtDecrypt(token, matchingKey.privateKey)
console.log('解密成功:', payload)
4.2 创建 JWKS 端点(Express 示例)
// Express JWKS 端点 — 标准密钥分发方式
import express from 'express'
import * as jose from 'jose'
const app = express()
// 存储密钥对(生产环境应使用 HSM 或密钥管理服务)
let currentKeyPair: {
kid: string
publicKey: CryptoKey
privateKey: CryptoKey
pubJwk: jose.JWK
}
// 初始化密钥
async function rotateKey() {
const { publicKey, privateKey } = await jose.generateKeyPair('RSA-OAEP-256', {
extractable: true,
})
const pubJwk = await jose.exportJWK(publicKey)
const kid = `enc-${Date.now()}`
pubJwk.kid = kid
pubJwk.alg = 'RSA-OAEP-256'
pubJwk.use = 'enc'
currentKeyPair = { kid, publicKey, privateKey, pubJwk }
}
await rotateKey()
// JWKS 端点(符合 RFC 7517)
app.get('/.well-known/jwks.json', (req, res) => {
res.json({ keys: [currentKeyPair.pubJwk] })
})
// Token 端点 — 使用当前密钥加密
app.post('/api/token', async (req, res) => {
const token = await new jose.EncryptJWT({
sub: req.body.userId,
role: req.body.role,
})
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
kid: currentKeyPair.kid,
})
.setExpirationTime('1h')
.encrypt(currentKeyPair.publicKey)
res.json({ access_token: token, token_type: 'Bearer' })
})
// 定期轮换密钥(每 24 小时)
setInterval(rotateKey, 24 * 60 * 60 * 1000)
app.listen(3000)
⚠️ 五、JWE vs JWS 嵌套(Nested JWT)
5.1 什么时候需要嵌套?
在高安全场景中,你可能同时需要签名和加密——先签名再加密(Sign-then-Encrypt)。这保证了:
- 完整性 + 来源验证(内层 JWS)
- 机密性(外层 JWE)
// 嵌套 JWT:先签名再加密(Sign-then-Encrypt)
import * as jose from 'jose'
// 1. 生成签名密钥对和加密密钥对
const signingKeys = await jose.generateKeyPair('ES256')
const encryptionKeys = await jose.generateKeyPair('RSA-OAEP-256')
// 2. 先创建签名 Token (JWS)
const signedToken = await new jose.SignJWT({
sub: 'user_12345',
role: 'admin',
clearance: 'top-secret',
})
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(signingKeys.privateKey)
// 3. 再将签名 Token 加密 (JWE)
const encryptedToken = await new jose.EncryptJWT({})
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
})
// 将 JWS Token 作为 plaintext 直接加密
// 使用 jose.CompactEncrypt 而不是 EncryptJWT
const nestedToken = await new jose.CompactEncrypt(
new TextEncoder().encode(signedToken)
)
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
})
.encrypt(encryptionKeys.publicKey)
console.log('嵌套 Token:', nestedToken)
// 4. 解密:先解密外层 JWE,再验证内层 JWS
const { plaintext } = await jose.compactDecrypt(nestedToken, encryptionKeys.privateKey)
const innerJws = new TextDecoder().decode(plaintext)
const { payload } = await jose.jwtVerify(innerJws, signingKeys.publicKey)
console.log('最终 payload:', payload)
// { sub: 'user_12345', role: 'admin', clearance: 'top-secret' }
💡 **提示:**嵌套 JWT 的标准处理顺序是 Sign-then-Encrypt(先签名后加密),而不是 Encrypt-then-Sign。原因:如果先加密再签名,攻击者可以用自己的密钥重新签名密文,接收方无法判断签名者是否是合法发送方。
🔐 六、浏览器端实现
6.1 使用 jose 的浏览器兼容版本
jose 库完全兼容浏览器,利用原生 Web Crypto API:
<!-- 浏览器端 JWE 加密解密 -->
<script type="module">
import * as jose from 'https://esm.sh/jose@6.0.0'
// 从服务器获取公钥(JWKS)
const jwksResponse = await fetch('https://api.example.com/.well-known/jwks.json')
const jwks = await jwksResponse.json()
// 导入公钥
const publicKey = await jose.importJWK(jwks.keys[0], 'RSA-OAEP-256')
// 加密敏感数据
const sensitiveData = {
credit_card: '6222****1234',
ssn: '***-**-1234',
}
const jweToken = await new jose.EncryptJWT(sensitiveData)
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
kid: jwks.keys[0].kid,
})
.setExpirationTime('5m')
.encrypt(publicKey)
// 发送加密 Token 到服务器
const response = await fetch('https://api.example.com/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jweToken}`,
},
})
</script>
📊 七、性能对比与选型建议
| 操作 | 算法组合 | 耗时(相对值) | 适用场景 |
|---|---|---|---|
| JWS 签名 | ES256 | 1x (基准) | 普通 API 认证 |
| JWE 加密(RSA) | RSA-OAEP-256 + A256GCM | 3-5x | 通用加密 Token |
| JWE 加密(ECDH) | ECDH-ES + A256GCM | 2-3x | 前向保密需求 |
| JWE 加密(对称) | A256KW + A256GCM | 1.5-2x | 内部服务通信 |
| 嵌套 JWT | ES256 + RSA-OAEP-256 | 5-7x | 最高安全需求 |
⚡ **关键结论:**不要因为「安全」就无脑使用 JWE。如果你的 JWT payload 不包含敏感信息(只有 sub、exp、iat 等公开声明),使用 JWS(普通签名 JWT)就足够了。JWE 的加密解密开销是签名验证的 3-5 倍,在高并发场景下需要权衡。
✅ 最佳实践与避坑指南
✅ 推荐做法
- ✅ 新项目首选
ECDH-ES+A256GCM— 兼顾前向保密与性能 - ✅ 始终验证解密后的
exp、iss、aud— 加密不等于可信 - ✅ 使用 JWKS 端点管理密钥 — 支持无停机密钥轮换
- ✅ 为每个密钥分配唯一
kid— 解密时精确定位密钥 - ✅ 设置合理的过期时间 — JWE Token 建议不超过 1 小时
❌ 避免做法
- ❌ 不要在 JWS 中存放敏感数据 — Base64 不是加密
- ❌ 不要使用
RSA1_5算法 — 已被 RFC 标记为不安全 - ❌ 不要使用
A128CBC-HS256的dir模式 — 除非有兼容性需求 - ❌ 不要在客户端存储私钥 — 私钥应保存在服务端或 HSM
- ❌ 不要跳过 AuthTag 验证 — 没有 AuthTag 的加密没有完整性保护
⚠️ 注意事项
- ⚠️ JWE Token 比 JWS Token 长得多(约 2-3 倍),注意 HTTP Header 大小限制
- ⚠️ ECDH-ES 每次加密生成临时密钥,不适合需要缓存解密结果的场景
- ⚠️ 密钥轮换时保留旧密钥至少 24 小时,确保已发出的 Token 仍可解密
- ⚠️ 在 Browser + Server 架构中,考虑使用混合模式:浏览器用公钥加密,服务端用私钥解密
🎯 总结
JWE 是 JOSE 规范家族中解决 JWT payload 明文暴露问题的标准方案。在选型时,根据你的安全需求和架构选择合适的算法组合:
| 需求 | 方案 | 复杂度 |
|---|---|---|
| 只需防篡改 | JWS (RS256/ES256) | 低 |
| 需要防读取 | JWE (RSA-OAEP + A256GCM) | 中 |
| 需要前向保密 | JWE (ECDH-ES + A256GCM) | 中 |
| 需要签名 + 加密 | 嵌套 JWT (Sign-then-Encrypt) | 高 |
核心库推荐:jose(零依赖、全运行时支持、TypeScript 友好)。
📌 **记住:**安全不是非此即彼的选择。在大多数 Web API 场景中,JWS(签名 JWT)已经足够安全。只有当 payload 包含真正敏感的数据(如 PII、财务信息、内部标识符)时,才需要升级到 JWE。过度加密只会增加复杂度和性能开销,不会带来额外的安全收益。