JWT 解码和验证的区别:能看懂载荷不等于令牌可信

更新于 5 分钟阅读

把令牌粘贴进解码工具,声明一条条列出来,sub 是对的,exp 在未来,于是很容易得出”这个 token 没问题”的结论。 其实这一步只证明有人读了一串字符,没有任何东西检查过它是不是你的签发方发出来的。

JWT 的三段内容谁都能读

签名的 JWT 是 header.payload.signature,三段都做 base64url 编码,再用点号连起来。头部和载荷是 JSON,而 base64url 只是一种编码,不是加密。最有名的那个头部 {"alg":"HS256","typ":"JWT"} 编码后是 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9,这也是几乎所有 token 都以 eyJ 开头的原因:{" 经 base64 编码之后就是这三个字符。 常见的示例载荷 {"sub":"1234567890","name":"John Doe","iat":1516239022} 编码后是 eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ。

解码不需要密钥,也不需要公钥。所以 JWT 里不该放任何保密信息:普通的已签名令牌(JWS)只保证内容没被改过,并不加密。 确实存在加密的令牌(JWE),但它有五段而不是三段,为签名令牌设计的解码工具读不了。

验证多做了什么

验证是用密钥对 header.payload 重新计算一遍签名,再和令牌里的签名比较。HS256 用共享密钥做 HMAC,RS256、ES256 则用签发方的 公钥去校验。只有这一步通过了,载荷里的内容才算得上”是谁写的”的证据。之后还得逐项判断声明:

检查项什么时候失败
签名令牌被改过,或用的是另一把密钥
exp令牌已经过期
nbf令牌还没到生效时间
iss签发方不是你信任的那个
aud令牌是发给别的服务的

页面下方链接的 JWT 解码工具在这些步骤之前就停下了。它自己标注”仅解码,未验证”,从不校验签名,状态栏里也一再说明,因为 能读懂载荷并不代表可信。它给出的 exp 结论只涉及时间,不涉及真伪。

代码里这两个操作经常只差一个单词。在 Node 的 jsonwebtoken 库中,jwt.decode(token) 只返回载荷,什么都不检查;请求链路上 应该用的是 jwt.verify(token, key, options)。jose、PyJWT 这类库默认只提供验证的接口,也是出于同样的考虑。

alg 头部是攻击者说了算的

头部指明用哪种算法,而头部是发送方写的。由此产生两个经典漏洞:

  • alg: none。 头部为 {"alg":"none"}、签名为空的令牌,本身是合法的”无保护 JWT”。服务端如果头部写什么就认什么,就会放行它。 本工具的示例令牌正是这样构造的,签名段为空,并且会提示你这一点。
  • 算法混淆。 期望 RS256 的服务端手里有签发方的公钥,而公钥不是秘密。如果库允许头部选择 HS256,攻击者就能把这把公钥当成 HMAC 的密钥去签伪造的令牌,服务端校验也会通过。

两者的防御办法相同:在 verify 调用里显式给出允许的算法清单,例如 { algorithms: ['RS256'] },不要信任头部。kid 也是 一样,它只是一个不可信的提示,用来从你控制的密钥集合里挑选密钥,绝不能直接拼进文件路径或查询语句。

exp、nbf、iat 的单位是秒

这几个声明是 NumericDate,即自 Unix 纪元起的秒数。而 JavaScript 的 Date 要的是毫秒,所以 new Date(payload.exp) 会落在 1970 年 1 月, Date.now() > payload.exp 永远为真。

const expired = Date.now() / 1000 >= payload.exp;

反过来,如果签发时把 exp 写成了毫秒,这个令牌看上去要到公元五万七千年左右才过期,实际上就是永不过期。解码工具会把每个时间声明显示为带时区名的 本地日期时间,再附一句”5 分钟前已过期”之类的相对时间,两种错误都能一眼看出来。如果令牌在签发后几秒就被拒绝,先怀疑服务器之间的时钟漂移, 给 exp 和 nbf 留一点宽限;多数库都有对应的选项。没有 exp 的令牌永远不会过期,工具会明确指出这一点,而不是说它”正常”。

JWT 解码中文乱码

把含有 "name":"张三" 的载荷拿去给 atob 一行代码解码,得到的是 å¼ ä¸ 这类乱码,因为 atob 每个字节返回一个字符,而声明是 UTF-8。 手写解码还有两个容易踩的坑:字母表用的是 - 和 _,不是 + 和 /,atob 会直接拒绝;结尾的 = 填充也被去掉了。正确的做法是先替换字符、 补回填充,再把字节按 UTF-8 解码:

function decodePart(part) {
  const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
  const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='));
  return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
}

本工具做的就是这些,并且会去掉你复制整个请求头时带上的 Bearer 前缀;当输入不是令牌时(例如只找到两段而不是三段)会指出出问题的是哪一段。

服务端验证的最小写法

以 jose 为例,一次完整的验证把签名、算法、签发方和受众放在同一个调用里,exp 和 nbf 由库自动检查:

import { jwtVerify } from 'jose';

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'my-api',
});

调用失败就是令牌不可信,不要退回去用解码结果”凑合”。常见的反模式是:先 decode 取出 userId,再去查库,然后才想起来验证, 或者干脆在网关验证了、在业务服务里重新 decode 并假定网关一定在前面。后者在内部网络被绕过的那一天会出事。

解码结果和预期不一致时怎么排查

  • 提示”没有点号”或”只有两段”:多半是复制时漏掉了后半截,或者复制到的是 refresh token、会话 id 之类根本不是 JWT 的东西。 五段的令牌是 JWE,内容被加密,解码工具读不出来。
  • 第一段能解、第二段报错:检查是不是日志系统在中间插入了换行或省略号,base64url 里不会出现空格。
  • 能解码但内容”不对”:aud 可能是字符串,也可能是数组,代码里只写 payload.aud === 'my-api' 会漏掉数组的情况; 自定义声明的名字大小写也要核对,JSON 的键区分大小写。
  • 解码正常、验证失败:先看 alg 和 kid,确认你拿的是对应的那把密钥;再看密钥是否轮换过。时间类的失败参照上一节的单位和时钟漂移。

复制令牌时该注意什么

在过期之前,一个有效令牌就等同于一份凭据。在不发任何请求的页面里解码,令牌就不会离开你的机器,本页面链接的工具就是这样工作的;但更稳妥的习惯依然 成立:生产环境的令牌只在你信任的地方解码,贴进工单或聊天之前先脱敏,并且尽量使用有效期短的令牌。

打开工具: JWT 解码工具

返回指南列表

更多指南