把 JWT 用成事故的常见错误

JSON Web Token 是一份签了名的声明,不是一个密封的信封。它是由点号连接的三段 Base64url 编码内容——一个指明签名算法的头、一个装着关于用户或会话的声明(claims)的载荷、以及对前两段的签名。签名证明这份令牌由持有密钥的一方签发、且此后未被改动。它对保密性什么都不证明:任何持有令牌的人都能解码载荷、读出其中每一条声明——这就是为什么解开一个令牌只需要粘贴,而不需要密码。JWT 在生产环境中出事时,坏掉的几乎从来不是密码学本身。坏掉的是某人跳过的一项检查——从令牌自身轻信来的算法、没人验证的过期时间、人类能凭记忆打出来的共享密钥。把任何令牌粘贴到JWT 解码器里,它的头和声明就明明白白摆在眼前。本文逐个讲清那些把跳过的检查变成数据泄露的工程错误,以及关上每个错误的那条防御规则。

签了名不等于保密

载荷是 Base64url——一种编码,与普通 Base64 同族——而不是加密。其中一切都能被令牌途经的任何人读到:电子邮件地址、角色、租户 ID、内部账号编号,偶尔还有糟糕得多的东西,比如密码重置标志、或开发者为调试加进去又忘了删的个人数据片段。令牌的足迹也比开发者想象的远:它们躺在浏览器存储里,穿过代理与应用日志,被复制进崩溃报告,又被粘贴到缺陷单和聊天记录里。由此直接得出的工作规则是:永远不要把一条你不乐意看到它印在日志文件里的声明放进令牌——因为迟早它会出现在那里。

这种误解情有可原——紧凑的令牌看起来像密文,全是读不出声的字符和点号。但签名只让令牌可篡改检测:改动任何一条声明,验证就会失败。保密性是同一标准家族中的另一种构造(加密令牌,JWE),而普通访问令牌很少是它。所以把载荷当作签了名的公开文本来设计:只放标识符与授权事实,敏感数据在服务端按 ID 查询,生命周期保持短暂,让万一泄露出去的东西尽快过期。

让令牌自己挑选算法

头部声明了一个 alg,而 JWT 领域最古老的失败就是验证方相信了它。声明值之一是 none——无签名令牌,根本没有签名。一个遵从令牌自报算法的库或手写检查,可能收到一个签名为空的令牌并把它当作真的接受。最佳实践 RFC 在这一点上说得很直白:无签名令牌只有在通道本身已经保证完整性时才可接受,而一枚经由公共互联网到达的持有者令牌并不满足这一点。同一种「相信头部」的缺陷会产生算法混淆:一个签发 RS256 令牌的系统持有私钥,并发布配套的公钥用于验证。若验证方能被说服改按 HS256 处理这枚令牌,验证材料就变成了那把公钥——按设计就是公开的、人人可知——被当作对称密钥来用,于是任何人做出的签名都能通过验证。这两种失败里都没有密钥被盗;验证方只是照办了发送方写下的指令。

头部还带着第二种更安静的风险:密钥指针。kid(密钥 ID)、jku 与 x5u(存放密钥或证书的 URL)这类参数告诉验证方去哪里找验证密钥——而它们就坐在受攻击者影响的令牌内部。若一个 kid 值未加严格校验就流入文件路径、数据库查询或密钥抓取,查找动作本身就成了攻击面。防御规则形状都一样:在服务端配置中钉死预期的算法、拒绝其余一切;从你自己的注册表中解析密钥,密钥 ID 对照允许列表核对;永远不要从令牌提供的 URL 抓取密钥材料;把每个头部字段都当作不可信输入,因为它就是。

签名通过了;声明却从没被检查

验证签名只回答一个问题——这枚令牌是否由密钥持有者签发且未被改动?它对这枚令牌是否应该在此刻、在这里被接受什么都不说。那是声明的工作,而声明验证正是生产系统悄悄跳步的地方。先是 exp:一个从不检查过期时间的验证方,会把每枚令牌都变成永久凭证,于是三年前在日志里泄露的令牌今天仍能通过认证。其次是 iss:只接受你配置过的签发者发出的令牌,而不是碰巧使用同一算法的任何人。然后是 aud,代价最大的遗漏:组织常让多个服务共用一把签名密钥,于是低价值服务的令牌——免费层 API、靠近预发环境的工具——成了一枚签名完全为真、却会被高价值服务接受的令牌,因为签名是真的,而没人问过这枚令牌是为哪个受众签发的。

这些都不冷门。这是一张清单,而失败模式是:库热情地验证签名,却把声明检查留作可选、默认关闭或调用方的责任。防御姿态是:每次验证都要求 exp、iss 与 aud,任何缺失或不符都失败关闭;时钟偏差容差以分钟计而非小时——容差是给不准的时钟用的,放得太宽就会悄悄把过期令牌重新续命。然后像测试登录表单一样测试否定情形:过期令牌、受众错误的令牌、未知签发者的令牌都应该被拒绝,而这些拒绝本身值得作为安全信号记录到日志里。

人记得住的密钥,就是攻击者能试出来的密钥

HS256 是对称的:一把共享密钥既签名又验证。在真实代码库里,这把密钥太常是一个词或短句——公司名、从教程里抄来的框架默认值、开发者能凭记忆打出来的东西。让这一点致命的原理如下,它是设计本身的属性而非某个工具的属性:一枚被捕获的令牌带着离线测试猜测所需的一切。消息已知、签名已知,于是猜测可以在本地全速核对,服务器永远看不到一次尝试,任何速率限制都不适用。于是整个方案的安全性归结为那一串字符的熵——而人类选的字符串熵极低。

修复办法毫不耀眼。用机器生成签名密钥,长度要够——HMAC 至少 256 比特熵——并像对待其他生产凭据一样保管它们。每个环境唯一,让泄露的预发配置里的密钥在生产环境一文不值;永远别让开发默认值活着进入真实部署。当许多服务要验证令牌时,优先选用 RS256 或 ES256 这类非对称算法:验证方只持有公钥,于是验证方被攻破也泄露不出能铸造令牌的东西,签名密钥只留在一处。并且在需要之前就准备好轮换方案,因为对称密钥泄露时没有警报——第一个迹象通常是你没签发过的令牌被当作你的接受了。

浏览器把令牌存在哪里,决定了谁能读到它

在客户端,存储选择是一项安全决策,不管有没有人有意识地做过它。放在 localStorage 里的令牌可被页面上运行的每个脚本读到:你自己的代码、你放进来的每个第三方标签和分析片段——以及任何曾执行过的跨站脚本负载,哪怕它只来自应用里任意一处未转义的评论字段。持有者令牌是一份可复制的凭据:读到它的人可以在任何地方出示它,并在过期前就是那个用户。因此,脚本可读存储里的长效令牌,其价值恰好等于你最糟的那个 XSS 漏洞,而大多数应用迟早会有一个。

通常的替代方案 HttpOnly Cookie 完全移除了脚本访问——XSS 负载再也无法读取并外带令牌——但浏览器会自动附带 Cookie,于是跨站请求伪造成了要去对付的威胁,用 SameSite 属性与 CSRF 令牌来应对。只把令牌放在内存里能同时躲开两个问题,代价是每次刷新都丢失会话。并不存在放之四海皆准的正确答案,假装有,正是团队最后两头落空的途径。把存储变成泄露事件的,是那个没人有意选过的组合:脚本可读的存储、长生命周期、宽泛的权限范围、没有刷新令牌轮换来压缩副本的价值。有意识地选择这个取舍,无论哪种方案都保持访问令牌短效,并把理由写在下一个开发者能找到的地方。

来源与延伸阅读

常见问题

JWT 里面的数据是加密的吗?

不是。在标准签名 JWT 中,头部与载荷是 Base64url 编码的,任何人都能解码——在 JWT 解码器里打开一枚令牌,每条声明都是明文可读的。签名提供的是完整性与来源证明,而不是保密性。加密 JWT(JWE)存在,但在实践中是例外。把每条声明都当作对持有令牌的人公开,也对令牌途经的每个日志、代理与截图公开。

我们验证了签名。为什么这还不够?

因为有效签名只证明令牌由密钥持有者签发且此后未被改动。它对令牌是否已过期(exp)、是否来自你信任的签发者(iss)、是为你的服务还是为共用同一密钥的另一个服务签发(aud)什么都不说——如果你的验证方让令牌挑选算法或提供密钥位置,它同样什么都不说。签名验证是验证的前半部分。声明清单、加上在你一方钉死的算法,是后半部分,泄露事件就住在两者之间的缝隙里。

令牌应该放在 localStorage 还是 Cookie 里?

每种选择都是拿一种攻击换另一种。localStorage 可被页面上的任何脚本读取,于是一个 XSS 缺陷或一个恶意第三方脚本就能复制走所有已存令牌。HttpOnly Cookie 根本不能被脚本读取,但它会被自动发送,于是 CSRF 成了要用 SameSite 与 CSRF 令牌管理的威胁。配合短效访问令牌与刷新令牌轮换,两者都可以站得住。站不住的是默认式选择、用长生命周期、且从不回头复查这个决定。