安全指南

如何安全地解码与验签 JWT

JWT 三段分别装了什么、为什么解码什么都证明不了、注册声明应当如何被强制校验,以及算法混淆是怎么防住的。

JSON Web Token 是用点连接起来的三段 Base64URL 文本。任何拿到 token 的人都能读出其中任意一段——这正是解码器在排查问题时好用的原因,也正是「解码」作为安全检查毫无价值的原因。验签是另一件事,它需要密钥、预期算法和一个时钟。

三段内容,其中两段是公开的

签名后的 JWT(规范里叫 JWS Compact Serialization)形如 header.payload.signature。头部和载荷是用 Base64URL 编码的 JSON:这套 URL 安全字母表把 + 换成 -、把 / 换成 _,并去掉末尾的 = 填充。它是编码,不是加密,里面不含任何密钥。任何带 Base64 解码器的语言,一行代码就能把前两段还原成可读 JSON。

第三段是唯一携带安全属性的部分,而且只有一条:它证明生成这个 token 的一方当时持有签名密钥,并且前两段此后没有被改动过。它并不说明 token 是否已过期、是否是发给你这个服务的,也不说明它指向的用户是否还存在。

头部真正决定了什么

头部里有 alg,指明签名算法;通常还有 typ,惯例取值是字符串 JWT。另有两个参数在实践中很关键。kid 是密钥标识,让签发方可以在不打断在途 token 的前提下轮换密钥;你的验签方按 kid 查找密钥时,必须把这个值当作不可信输入,因为它来自调用方。jku 和 jwk 则分别是「指向密钥集的 URL」和「内联的公钥」。

关键的心智模型是:头部是攻击者可控的数据,而它描述的是「如何校验攻击者可控的数据」。过去十年里所有危险的 JWT 失效模式,都源于某个库读了头部然后照做。如果验签方会去 token 里的 jku 地址取密钥,或者信任 token 里内联的 jwk,那它等于在请调用方自己提供用来验证自己签名的密钥。

安全的配置很无趣:应用在看到 token 之前就已经确定了算法和密钥来源。头部的 alg 只用来和这个预期做比对,不一致就拒绝,而不是用它去挑选验签例程。jku 和 jwk 要么关闭,要么限制在签发方控制的白名单来源上。

注册声明,以及它们应当如何被校验

RFC 7519 定义了七个注册声明。它们全部是可选的,token 格式本身不会强制其中任何一条,而一个把它们显示出来的解码器,什么都没有检查。强制校验是你的验签代码的职责,每条声明都对应一个具体的比较动作。

线上 bug 大多出在时间类声明上。exp 和 nbf 要和当前时间比较,并允许一点点时钟偏差——六十秒是常见取值,再放大就开始抵消短时效 token 的意义了。iat 是信息性的,不该拿来代替过期判断。还要注意这三个都是 NumericDate:自 Unix 纪元起的秒数,不是毫秒。把 JavaScript 的 Date.now() 直接塞进 exp,得到的 token 会在公元 56000 年过期,而一个实现正确的验签方会一声不响地接受它。

第三列是你的服务端必须执行的检查。代码里没有这一步,这条声明就只是装饰。

声明含义验签方必须做什么
iss签发方——谁签出了这个 token与你信任的签发方字符串精确比对;并用它来选择密钥集。
sub主体——token 描述的是谁当作不透明标识符。它只在单个签发方内唯一,所以业务记录要用(iss, sub)做键。
aud受众——token 是发给谁的除非其中包含你自己的服务标识,否则拒绝。取值可能是字符串,也可能是数组。
exp过期时间(NumericDate,秒)当前时间达到或超过 exp 即拒绝,允许少量时钟偏差。
nbf生效时间(NumericDate,秒)当前时间早于 nbf 即拒绝。经常被省略,这没问题。
iat签发时间(NumericDate,秒)可选的新鲜度检查——超过策略上限的 token 予以拒绝。绝不能代替 exp。
jtitoken 唯一标识只有配合服务端状态才有用:以 jti 为键的重放缓存或吊销名单。

实例:手工解码一个 token

下面这个 token 是各类 JWT 文档里长期使用的公开演示 token,用 HS256 签名,密钥是众所周知的非机密字符串 your-256-bit-secret,因此完整贴出来是安全的。按点切开,对前两段做 Base64URL 解码,JSON 就出来了。

注意载荷里缺了什么:既没有 aud 也没有 exp。为这个 token 配置的验签方,既没有东西可以核对受众,也没有过期时间可以强制,于是它会永远接受这个 token。token 的强度上限,就是你的验签方坚持要求的那些声明。

一个完整的 HS256 token 及其解码结果
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

第 1 段(头部),Base64URL 解码后:
{"alg":"HS256","typ":"JWT"}

第 2 段(载荷),Base64URL 解码后:
{"sub":"1234567890","name":"John Doe","iat":1516239022}

  iat 1516239022 = 2018-01-18T01:30:22Z
  没有 exp  -> 永不过期
  没有 aud  -> 没有受众限制
  没有 iss  -> 无法按签发方挑选密钥集

第 3 段(签名)是原始字节,不是文本。它是
HMAC-SHA256(secret, 第1段 + "." + 第2段) 再做 Base64URL 编码。
把它解码出来是 32 字节摘要,单看没有任何意义——
你只能重新计算一遍再比对。

alg: none 与算法混淆

第一种失效模式是「未加固的 JWT」。RFC 7519 允许 alg 取字符串 none,此时签名段为空,token 按定义就是未经验证的。这个选项本是给「完整性由外层保证」的场景准备的。危险在于:如果验签方读取头部的 alg 再据此分派,那么收到一个头部写着 none 的 token,它就会走「无签名」分支并返回成功。现在还在维护的库都默认拒绝 none,除非你显式开启——但这个开关仍然存在,而锁定在旧版本的部署也仍然存在。

第二种是算法混淆,只要服务会验证非对称 token 就适用。RS256 验签使用公钥,HS256 验签使用共享密钥。如果一个库只有 verify(token, key) 这一个入口、并从头部挑选算法,那么收到一个头部写着 HS256 的 token 时,它会把 RSA 公钥的字节当成 HMAC 密钥来用。而公钥是公开的,于是任何人都能造出能通过校验的签名。缺陷既不在 RSA 也不在 HMAC,而在于让 token 自己决定用哪一个。

两者的防御完全相同,而且今天就该写进代码:在验签调用里显式传入预期算法,作为只含一两个取值的白名单;密钥从你自己的配置里加载,而不是从 token 里的任何字段。多数库里这是一个具名参数——algorithms: ['RS256'] 或其等价写法。任何「不告诉它该用什么算法也能验签」的调用都是配置错误,不管库有多新。

浏览器里该把 token 放哪

不存在对一切攻击都安全的存储位置:每个选项都是用一类攻击面换另一类,你要挑的是自己真正有能力缓解的那一类。

localStorage 和 sessionStorage 可以被同源下运行的任何 JavaScript 读取,包括每一个第三方脚本、每一段统计代码、以及打包产物里的每一个依赖。只要同源的任何位置存在 XSS,token 就没了。作为交换,它天然免疫 CSRF,因为没有任何机制会自动携带它。

带 HttpOnly、Secure、SameSite 标记的 Cookie 无法被 JavaScript 读取,XSS 载荷偷不走它——但仍然可以借受害者浏览器发起已认证的请求,这个损失更小,却是真实存在的。代价是 Cookie 会被自动携带,于是你需要 CSRF 防护:SameSite=Lax 或 Strict 能覆盖多数场景,剩下的用 anti-CSRF token 补上。

实践中最经得起考验的模式是:短时效的访问令牌只放在 JavaScript 变量里、绝不持久化,再配一个作用域限定在令牌端点的长效刷新令牌,放进 HttpOnly + Secure + SameSite 的 Cookie。页面刷新会丢掉访问令牌,然后静默换发一个新的。代价是一次往返,换来的是小得多的暴露窗口。无论你选哪种,都要把访问令牌的有效期压短,因为持有型令牌在过期之前就等同于密码,而在没有服务端状态的前提下,你没有办法把它「收回」。

什么可以粘进解码器,什么绝对不可以

本站的 JWT 工具在页面内、在你的浏览器里完成解码,不会把 token 发出去。这是一个真实的性质,也确实有价值。但它和「安全」不是一回事,我们宁愿把话说清楚,也不想让一枚隐私徽章替你完成判断。

签名密钥则完全是另一类东西。RSA 或 ECDSA 私钥、HMAC 密钥,它们不会过期,不限定于某一个用户,而且能为所有人签发 token。它不该被粘进任何网页——我们的也好,别家的也好,本地处理也好。它一旦进入输入框,就同时进入了页面内存、你的剪贴板历史、可能还有某个扩展的 DOM 监听,并且离「在别处被表单自动填充提交」只差一步。请在你自己掌控的机器上用命令行工具或自有测试套件完成验签。

要点回顾

  • 解码 JWT 不需要密钥,也证明不了任何事;只有用你自己配置的密钥完成验签,才构成证明。
  • 每一次验签调用都显式传入算法白名单,密钥从自己的配置加载,绝不信任 token 头部的 alg、jku 或 jwk。
  • exp、aud、iss 必须在代码里强制校验——解码器把它们显示出来并没有做任何检查;另外 exp 的单位是秒,不是毫秒。
  • 按「你能缓解哪类攻击」来选存储位置:localStorage 输给 XSS,Cookie 需要 CSRF 防护,内存加刷新 Cookie 通常是最划算的折中。
  • 永远不要把签名密钥或私钥粘进任何网页工具,包括我们的;token 可以随便解码,验签请在你掌控的机器上做。

继续了解相关检查与工具