JWE 完全指南:JSON Web Encryption 的原理、实现与安全实战

深入解析 JWE (JSON Web Encryption) 加密机制,涵盖五大加密算法对比、Node.js 与浏览器端完整实现、JWKS 密钥管理与生产环境安全加固,附可运行代码示例。

安全与密码 2026-06-12 20 分钟

大多数开发者只知道 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)。这保证了:

  1. 完整性 + 来源验证(内层 JWS)
  2. 机密性(外层 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 不包含敏感信息(只有 subexpiat 等公开声明),使用 JWS(普通签名 JWT)就足够了。JWE 的加密解密开销是签名验证的 3-5 倍,在高并发场景下需要权衡。

✅ 最佳实践与避坑指南

✅ 推荐做法

  • 新项目首选 ECDH-ES + A256GCM — 兼顾前向保密与性能
  • 始终验证解密后的 expissaud — 加密不等于可信
  • 使用 JWKS 端点管理密钥 — 支持无停机密钥轮换
  • 为每个密钥分配唯一 kid — 解密时精确定位密钥
  • 设置合理的过期时间 — JWE Token 建议不超过 1 小时

❌ 避免做法

  • 不要在 JWS 中存放敏感数据 — Base64 不是加密
  • 不要使用 RSA1_5 算法 — 已被 RFC 标记为不安全
  • 不要使用 A128CBC-HS256dir 模式 — 除非有兼容性需求
  • 不要在客户端存储私钥 — 私钥应保存在服务端或 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。过度加密只会增加复杂度和性能开销,不会带来额外的安全收益。

📚 相关文章