JWE实战:从JWT裸奔到端到端加密,API安全令牌升级指南
2026/9/24 19:49:40 网站建设 项目流程

大概一年前我接手了一个对外API网关项目,所有受保护接口的身份令牌统一用的是JWT。当时团队对JWT的评价很高:无状态、跨语言、有RFC标准、签名验签也不复杂。直到一次安全评审,同事拿着线上真实Token贴到jwt.io页面里,几秒钟就把用户手机号、邮箱、内部工号全看光了。那一刻我才意识到,我们以为在用的“安全令牌”,其实只是“防篡改的明文信封”,而不是私密信封。从那时起我开始认真调研JWE,并在后续项目里把核心API的令牌从JWT升级成了JWE。这篇内容就是那段时间的实战沉淀,适合已经被JWT“惯坏”、又希望API敏感数据真正做到端到端加密的同学参考。

1. 为什么JWT不够用:我在线上踩到的明文Payload坑

1.1 JWT的三段式结构不是加密,只是编码

JWT的标准格式是Header.Payload.Signature三段,Header和Payload都使用BASE64URL编码。BASE64URL不是加密算法,它只是一种“编码表做了替换”的Base64变种,任何人拿到Token都能在几秒钟内解码出明文内容。签名的作用是完整性校验,解决的是“Token有没有被篡改”的问题,而不是“Token里的信息有没有暴露”的问题。

很多团队会把“签名”和“加密”混为一谈,这是最危险的地方。我当时也犯了这个错,以为JWT做了签名就安全了,实际上Payload里的用户手机号、邮箱、工号全部裸奔。签名只是保证这些字段不能被偷偷改掉,但没任何机制保证这些字段不被看到。

举一个直观例子:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTAwMSIsInBob25lIjoiMTM4MDAwMDAwMDAifQ.signature

中间那段eyJzdWIiOiJ1c2VyLTAwMSIsInBob25lIjoiMTM4MDAwMDAwMDAifQ,用任意一个BASE64解码工具就能得到:

{"sub":"user-001","phone":"13800000000"}

如果把这类Token放在前端浏览器、第三方回调URL、网关访问日志里,等于把用户隐私直接写在了信封正面。

1.2 什么时候JWT会“裸奔”

并不是说JWT完全不能用,而是要搞清楚哪些场景下JWT的明文Payload会形成实际风险。我归纳了三个最容易出问题的场景:

第一个场景:前端持有的Access Token。浏览器开发者工具里能看到完整Token,一旦有人通过XSS脚本或浏览器插件把Token抓走,Token里的身份证号、手机号直接就泄露了。更麻烦的是,很多前端项目会把Token打点上报到日志平台,等于二次扩散。

第二个场景:第三方OpenAPI的回调参数。我们当时有一个对外开放的接口,合作方需要把用户标识传回来做回调验签。为了省事,直接把整个JWT放在回调URL的state参数里,结果第三方网关的访问日志把完整URL记录了下来,里面包含了用户手机号。这个问题的严重性不在于签名被破解,而在于明文信息根本没有保密能力。

第三个场景:OIDC协议中的ID Token。OpenID Connect里返回的ID Token默认就是JWS格式,直接携带用户身份信息。如果内部多个服务之间继续转发这个Token,用户信息就会被无关服务看到,扩大了数据暴露面。

1.3 什么样的接口才真正需要JWE

JWE不是万能药,为所有接口都上加密反而会增加复杂度和性能开销。从我的经验看,下面这几类接口应该优先考虑JWE:

  • Token中携带手机号、邮箱、身份证号、家庭住址等用户隐私字段
  • 令牌需要跨越不可信的第三方网络或终端
  • Token会出现在访问日志、回调参数、浏览器存储等易被读取的位置
  • 使用OIDC协议颁发ID Token,且该Token可能被下游服务继续传递
  • 作为开放平台API的凭据凭证,需要防止合作方在传输过程中逆向读取内部字段

如果只是纯内部服务之间调用,网络边界有mTLS保护,JWT的性能和简洁性优势仍然值得保留。我现在的原则是:加密留给公网和隐私,签名留给内部和性能。

2. JWE工作原理拆解:不是“JWT加了个密”这么简单

2.1 JWE紧凑序列化的五个字段

先从概念上理清一个容易混淆的点:JWT、JWS、JWE其实是三个维度的东西,不是三个竞争方案。JWS和JWE是JOSE框架下两种并行的安全格式,分别负责签名和加密;而JWT是一种承载Claims的令牌容器,它可以以JWS形式序列化,也可以以JWE形式序列化。所以更准确的说法是:JWE不是JWT的替代品,而是JWT家族里的加密分支。

JWE紧凑序列化结构一共有五个字段,用点号分隔:

BASE64URL(Header).BASE64URL(EncryptedKey).BASE64URL(IV).BASE64URL(Ciphertext).BASE64URL(Tag)

这五个字段分别负责什么,我用一个表格说明:

字段作用通俗理解
Header声明加密算法、密钥ID、Token类型封面上的“使用说明”
EncryptedKey被公钥或共享密钥保护后的内容加密密钥保险柜里藏的临时钥匙
IV加密时使用的初始化向量给每条消息加的随机盐
Ciphertext真正的密文锁起来的文件本体
TagAEAD认证标签,用于完整性校验封条上的防伪标记

2.2 alg和enc各管一段:密钥管理与内容加密

JWE的加密过程和我一开始想的不一样,它不是拿公钥直接加密整个Payload。实际流程分两层:

  1. 随机生成一个临时内容加密密钥(CEK),比如A256GCM需要256位随机密钥
  2. alg指定的密钥管理算法保护这个CEK,比如用RSA公钥加密CEK,或者通过ECDH协商派生CEK
  3. 用CEK加上随机生成的IV,通过enc指定的内容加密算法(推荐AES-GCM)对明文Payload加密
  4. 最后生成密文和认证标签,组装成五段式Token

为什么要多此一举搞一个临时CEK?这个问题我当时也困惑过。核心原因有两个:RSA这类非对称加密算法处理不了长明文,2048位RSA公钥加密最多也就能加密约245字节;而AES-GCM在硬件加速下吞吐量极高,适合加密任意长度的消息。用保险柜(KEK)保护临时钥匙(CEK),再用临时钥匙打开抽屉里的文件,就是经典的混合加密思想。

alg字段常见取值包括RSA-OAEP-256ECDH-ESA128KW等,enc字段常见取值包括A256GCMA128CBC-HS256等。需要注意,EncryptedKey字段在某些算法下可能为空。比如ECDH-ES模式下,CEK是通过发送方临时密钥和接收方静态密钥协商派生出来的,不是加密传输的,所以这个字段为空字符串,但五个字段之间的点号分隔符依然保留。

2.3 算法组合怎么选:推荐与避雷

算法选型直接决定安全和性能,这块不能偷懒照抄。我把自己实测过的组合整理成一张表:

算法组合适用场景推荐程度
RSA-OAEP-256 + A256GCM公钥加密、私钥解密,最通用强烈推荐
ECDH-ES + A256GCM性能更好,适合高并发推荐,但注意库兼容性
A128KW + A256GCM双方共享对称密钥推荐,密钥管理需到位
RSA1_5 + A128CBC-HS256老系统兼容坚决不用
A128CBC-HS256 + A128CBC-HS256非AEAD组合慎用,容易用错

RSA1_5之所以被拉黑,是因为存在Bleichenbacher攻击,攻击者可以通过不断修改密文并观察解密结果来逐步还原被加密的密钥,这是已经有多篇论文验证过的老问题。AES-GCM属于AEAD(认证加密)算法,加密的同时自带完整性保护,不额外需要HMAC,用起来安全边界更清晰。CBC+HMAC的组合虽然也能达到类似效果,但加密与MAC的顺序、密钥分割这些细节很容易配错,我建议新手尽量避开。

3. JWE实战:从依赖选型到跑通加密解密

3.1 开发库选型

语言生态里JWE库不少,但我实际用下来最稳妥的是这两个:

  • Java:Nimbus JOSE + JWT,Maven坐标com.nimbusds:nimbus-jose-jwt,文档全、社区活跃
  • Node.js:jose,npm包名就是jose,API设计现代,支持Web Crypto底层实现

选库时有一个容易被忽略的点:看它是否支持你选定的alg算法。有些老库对ECDH-ES支持不完整,只支持RSA系列。我建议在技术选型阶段先用小Demo把加密、解密跑通,再决定是否引入生产环境,不要直接照搬别人的配置。

3.2 Java示例:RSA-OAEP-256 + A256GCM

生成RSA密钥对并签发JWE:

import com.nimbusds.jose.*; import com.nimbusds.jose.crypto.RSADecrypter; import com.nimbusds.jose.crypto.RSAEncrypter; import com.nimbusds.jose.jwk.*; import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; import com.nimbusds.jwt.EncryptedJWT; // 生成2048位RSA密钥,注意use必须是ENCRYPTION RSAKey rsaKey = new RSAKeyGenerator(2048) .keyUse(KeyUse.ENCRYPTION) .keyID("rsa-2024-main") .generate(); RSAKey publicKey = rsaKey.toPublicJWK(); // 构造JWE Header JWEHeader header = new JWEHeader.Builder( JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) .keyID("rsa-2024-main") .contentType("JWT") .build(); // 明文Payload Payload payload = new Payload("{\"sub\":\"user-001\",\"phone\":\"13800000000\"}"); // 加密 EncryptedJWT jwe = new EncryptedJWT(header, payload); RSAEncrypter encrypter = new RSAEncrypter(publicKey); jwe.encrypt(encrypter); String jweToken = jwe.serialize();

解密:

EncryptedJWT parsed = EncryptedJWT.parse(jweToken); RSADecrypter decrypter = new RSADecrypter(rsaKey); parsed.decrypt(decrypter); String plaintext = parsed.getPayload().toString();

这里有一个我踩过的坑:生成RSA密钥时一定要设置keyUse(KeyUse.ENCRYPTION)。如果不设置,Nimbus默认有可能会把密钥标记成签名用途,虽然不设置也能跑,但部署到严格校验use字段的网关中间件时会直接报错。同理,如果有人给你提供公钥,也要确认公钥的useenc而不是sig

3.3 Node.js示例:JWE包裹签名JWT的Nested JWT

在实际项目里,我更推荐一种进阶玩法:先对Claims做JWT签名,再用JWE把整个签名Token加密。这样内层签名保证数据不被篡改,外层加密保证数据不被读取,两件事各司其职,这也是RFC里提到的Nested JWT思路。

const { generateKeyPair, SignJWT, CompactEncrypt, compactDecrypt } = require('jose'); async function main() { // 生成RSA密钥对 const { privateKey, publicKey } = await generateKeyPair('RS256'); // 第一步:内层签名JWT const innerToken = await new SignJWT({ sub: 'user-001', phone: '13800000000' }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime('5m') .sign(privateKey); // 第二步:外层JWE加密 const jweToken = await new CompactEncrypt(new TextEncoder().encode(innerToken)) .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .encrypt(publicKey); console.log(jweToken); // 解密还原内层JWT const { plaintext } = await compactDecrypt(jweToken, privateKey); const recoveredInnerToken = new TextDecoder().decode(plaintext); }

如果只想签发一个加密JWT、不需要内层签名,可以直接用EncryptJWT

const { EncryptJWT, jwtDecrypt } = require('jose'); const jwe = await new EncryptJWT({ sub: 'user-001' }) .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) .setIssuedAt() .setExpirationTime('2h') .encrypt(publicKey); const { payload } = await jwtDecrypt(jwe, privateKey);

Nested JWT最大的好处是解密之后拿到的仍然是一个合法JWT,下游服务如果想继续验签,不需要感知加密逻辑,只要用自己的公钥验内层签名即可,耦合度低很多。

3.4 密钥管理的正确姿势:kid、JWK Set与轮换

JWE的密钥管理比JWT复杂一个量级,因为JWT通常只有一把验签公钥,而JWE可能需要多把加密公钥并支持轮换。我目前的生产配置是这样的:

  • 每个密钥生成时分配唯一kid,比如rsa-2024-main
  • JWE Header里带上kid,解密方根据kid选对应私钥
  • 用JWK Set格式管理多把公钥,对外暴露一个/.well-known/jwks.json端点
  • 线上至少保留两把密钥:一个当前生效,一个旧密钥用于宽限期解密

一份标准的JWK Set公钥长这样:

{ "keys": [ { "kty": "RSA", "kid": "rsa-2024-main", "use": "enc", "alg": "RSA-OAEP-256", "n": "这里是RSA模数的BASE64URL编码", "e": "AQAB" } ] }

密钥轮换时不要把旧私钥直接删掉。正确做法是:先发布新公钥,让客户端逐步切换,等确认所有存量Token都解密成功后,再在密钥库里移除旧私钥。我见过一个团队因为轮换时直接删旧密钥,导致线上还有大量旧Token的用户全部登录失败,回滚也没用,只能强制用户重新登录,这是个惨痛教训。

4. JWE与JWT的对比:怎么选型才不后悔

4.1 不要非此即彼:三个概念先理清

网上很多文章把JWE和JWT放在对立面,我觉得不够准确。准确的关系是这样的:

  • JWS:基于签名的JOSE格式,三段式,保证完整性和不可否认性
  • JWE:基于加密的JOSE格式,五段式,保证机密性和完整性
  • JWT:承载Claims的令牌容器,既可以用JWS序列化,也可以用JWE序列化

所以标题里说的“比JWT更上一层楼”,更严谨的理解是“用JWE这个加密格式来承载JWT Claims,比单纯用JWS格式签名更安全”。它不是要替代JWT,而是在JWT外面再加一道保险柜门。

4.2 一张表格看清差异

维度JWT(JWS格式)JWE格式Nested JWT(JWE包裹签名JWT)
Payload可见性BASE64URL直接解码可见密文不可见密文不可见
防篡改签名保证AEAD认证标签保证内层签名+外层认证标签
Token体积最小较大最大
性能验签快解密较慢验签+解密最慢
调试便利性jwt.io直接看需要私钥解密需要私钥解密再验签
典型场景内部服务间无状态鉴权公网API隐私令牌第三方开放平台/OIDC ID Token

这个表格是我做技术方案时最常用的对照表。绝大部分情况下,内部服务用第一列就够了,公网API和涉及隐私字段的令牌用第二列或第三列。

4.3 我项目里的折中方案

回到我接手那个API网关项目,最终落地的方案不是一刀切全换JWE,而是分了两条链路:

对外暴露的OpenAPI接口,以及所有返回给浏览器前端的令牌,统一改成Nested JWT:内层是RS256签名的JWT,外层是RSA-OAEP-256+A256GCM的JWE。内层签名保证Token在解密后依然可以被各个服务独立验签,外层加密保证传输过程中和日志里看不到任何用户隐私。

内部服务之间的调用,为了性能考虑仍然保留普通JWT。因为内网环境有mTLS加密传输,再加上网络ACL控制,信息泄露风险已经很低,这时候用JWE就是纯属给CPU找活干。

这个方案的核心理念是:安全性要跟着数据暴露面走,不能所有接口一套令牌方案打天下。

4.4 需要谨慎使用JWE的场景

JWE也不是没有代价。如果遇到下面这几种情况,我建议你先把JWE放一放,想清楚再说:

场景一:Token要放在URL里。JWE体积比JWT大很多,一个普通JWT可能三四百字节,JWE轻松超过1KB甚至2KB。URL长度受浏览器、网关、中间件多重限制,放不下是常有的事。

场景二:极高并发且无状态服务。RSA-2048的解密操作是CPU密集型的,比JWT验签慢一两个数量级。虽然单次毫秒级看起来不吓人,但几万QPS放大下来,对网关算力有明显压力。

场景三:没有完整的密钥管理基础设施。JWE的性能和安全很依赖密钥管理系统。如果团队连kid都不知道是什么,私钥直接躺在Git仓库里,那用了JWE反而更危险,因为加密密钥泄露比签名密钥泄露更难被发现。

5. 踩坑与优化:JWE上线后的性能、缓存与排错

5.1 体积膨胀是意料之中也是意料之外

我第一次把线上JWT换成JWE时,Token体积从大约300字节膨胀到了将近2KB,直接导致三个问题:Cookie存储放不下、日志存储成本上升、前端接口响应体变大。这个问题不能靠“忍一忍”解决,要想办法控制。

我的做法是精简Claims字段。JWT时代习惯了往里塞用户名、部门、角色、权限列表,JWE时代这些能省则省。最终我的外层JWE只保留了sub和必要的时间字段,其他信息全部走sub去后端查询。Token体积降到了900字节左右,安全性反而更好,因为敏感信息不再跟着每个请求到处跑。

5.2 解密失败排查清单

JWE上线后,最让人头疼的就是解密失败。我整理了一份排查清单,基本能覆盖90%的线上问题:

密钥不匹配。检查kid在钥匙串里是否存在,不同环境(测试/预发/生产)的密钥是否搞混。这个占了我遇到问题的半数以上。

算法与密钥类型冲突。用RSA私钥去解ECDH-ES加密的Token,或者反过来,都会直接报错。检查Header里的alg字段和仓库里的密钥类型是否一致。

Token被截断或二次编码。JWE五段结构很长,放到URL后容易被代理截断。另外有些语言收到Token后会自动做一次URL decode,把Base64URL的-_转成别的字符,导致解析失败。

服务器时钟偏差。JWE标准支持nbfexp字段,校验时会和服务器当前时间比较。如果服务器时间差得太多,明明没过期的Token也会报错。用NTP校准时钟是基本操作。

BASE64URL Padding问题。某些库要求BASE64URL必须去掉=填充符,有些老库又要求必须有填充符。如果你的加密方和解密方用了不同语言的库,这一点最容易踩雷。

5.3 提升校验性能的实操技巧

JWE的RSA解密确实比JWT验签慢,但实际优化空间也很大。我在生产环境里用了三个方案,效果明显:

方案一:只解密一次,Claims透传。网关入口做一次JWE解密后,把Claims转成内部请求头传给下游服务,下游不再重复解密。这样整条链路里RSA解密只发生一次,性能损失可控。

方案二:Redis缓存解密结果。对于同一个Token在短时间内被反复校验的场景,比如网关限流插件和业务服务都会校验,可以用Token的完整字符串作为Key,把解密后的Claims JSON缓存在Redis里,TTL设3到5分钟。不过要注意,缓存必须同时校验exp字段,不能在缓存层把过期Token放行。

方案三:按客户端分密钥。给不同客户端分配不同的kid和密钥,可以避免所有高并发流量挤在同一把RSA私钥上,同时也方便针对某一客户端单独做密钥轮换。

5.4 上线前必须做的检查项

最后分享几个我是在出过一次事故后才总结出来的上线检查项:

第一,确认密钥绝对不在Git仓库里。我见过有人把JWK Set JSON直接提交到配置文件里,这个等同把保险柜钥匙放在保险柜旁边。生产环境的密钥应该交给托管密钥服务,拿不到密钥的人即使拿到JWE代码库也解不出明文。

第二,日志脱敏规则要提前设计。JWE本身已经隐藏了明文Claims,但有些团队图方便把解密后的Claims直接打日志,这就等于自己把保险柜门打开给人看。我现在的做法是:不允许在任何业务日志里记录解密后的Claims,只允许记录Token前几位用于排查定位。

第三,压测脚本必须覆盖加密和解密两条路径。JWT验签和JWE解密的性能特征完全不同,不要拿旧压测数据来评估新方案。我当时的经验是,JWE解密链路整体吞吐量比JWT下降了60%左右,但通过缓存和透传方案可以明显缓解。

如果让我重新做一次那个网关项目,我会从第一天就画清楚“哪些链路要加密,哪些链路只要签名”。JWE并不是要取代JWT,它是在JWT外面加的那道保险柜门。现在我们的对外API已经全部切换为Nested JWT方式,安全评审再也没拿“明文Claim”说事,虽然排查问题偶尔要写解密脚本,但比起用户隐私裸奔,这个代价完全值得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询