JSON Web Token 里到底装了什么

拆开一个 JWT 来看,讲解头部、载荷和签名,标准声明有哪些,以及为什么把令牌做 Base64url 解码并不等于验证了它。

JSON Web Token(JWT)是用句点连起来的三段文本。每一段都做过 Base64url 编码,而前两段就是普通的 JSON。任何拿到这个令牌的人都能读出里面的内容。一个正常的 JWT 里没有任何东西是加密的。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9      <- header
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbGV4In0   <- payload
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFW     <- signature

用句点连起来,就是你粘进请求里的那个令牌。把前两部分解码,你得到的是普通的 JSON:

{ "alg": "HS256", "typ": "JWT" }
{ "sub": "12345", "name": "Alex" }

真实的载荷通常还带着时间戳和受众:

{
  "iss": "https://auth.example.com",
  "sub": "12345",
  "aud": "api.example.com",
  "iat": 1756000000,
  "exp": 1756003600,
  "roles": ["editor"]
}

头部

头部描述这个令牌是怎么签名的。有两个字段最重要:

  • alg 指明签名算法,例如 HS256(用共享密钥的 HMAC-SHA-256)或 RS256(用 SHA-256 的 RSA 签名,用私钥签、用公钥验)。
  • kid,也就是密钥 ID,是可选的,它告诉接收方用的是若干密钥中的哪一个,这样就能在不作废现有令牌的前提下轮换密钥。

typ 通常就是 JWT,没有任何安全上的含义。

载荷及其声明

载荷是一个由声明(claim)组成的 JSON 对象,“声明”不过是规范用来指称“关于令牌主体的陈述”的说法。有些声明名称是标准登记过的,而且它们都很短:

声明名称它装着什么
iss签发者谁创建了这个令牌,通常是一个 URL 或服务名
sub主体这个令牌是关于谁的,通常是一个稳定的用户 ID
aud受众这个令牌是发给谁用的,好让一个服务能拒绝发给别的服务的令牌
exp过期时间在此之后令牌必须被拒绝的那个时刻
nbf生效时间在此之前令牌必须被拒绝的那个时刻
iat签发时间令牌是什么时候创建的
jtiJWT ID一个唯一标识符,可用于追踪或封禁单个令牌

expnbfiat 都是 NumericDate 值,也就是自 1970 年 1 月 1 日 UTC 以来的秒数。它们是秒,不是毫秒。如果一个时间戳解码出来落在 57000 年,那你读的是毫秒,需要除以一千。

载荷里的其他一切,都是签发方自己放进去的东西:角色、权限、一个邮箱地址、一个租户 ID。因为一个私有声明名称有朝一日可能与某个登记过的名称冲突,签发方常常用一个类似 URL 的前缀给自己的声明加上命名空间。

签名

签名是对前两部分用点连接起来的那段确切文本计算出来的,使用头部指定的算法和一个密钥。头部或载荷哪怕改动一个字符,签名就对不上了。

HS256 时,同一个密钥既用来签名也用来验证,所以任何能校验令牌的人也能伪造一个。用 RS256ES256 时,是私钥签名、公钥验证,这正是身份提供方能够发出令牌、让许多互相独立的服务在不持有任何机密的情况下就能验证它的原因。

Base64url 不是加密

Base64url 和 Base64 是同一个思路,只有两处不同:它用 -_ 代替 +/,好让结果在 URL 里是安全的;而末尾的 = 补齐符通常会被去掉。它是一种编码,一种把字节写成文本的可逆方式。它完全不提供任何机密性。

所以永远不要把敏感内容放进 JWT 的载荷里:不放密码,不放卡号,不放关于这个用户的内部备注。请假定持有令牌的人,以及任何从日志文件或浏览器存储里把它读出来的人,都能看到每一个字段。

另有一种独立的格式 JWE,它确实会加密载荷,而且它有五个部分而不是三个。如果你的令牌有三个部分,那它是签名过的,并且是可读的。

解码不等于验证

这是最要紧的一点。解码器展示的是令牌说了什么。它不告诉你这个令牌是不是真的。验证是单独的一步,而且需要密钥。

要真正信任一个令牌,服务器必须:

  1. 用正确的密钥校验签名。
  2. 坚持使用它自己预期的算法,而不是去相信令牌里的 alg 字段。跳过这一条会带来两种经典攻击:把 alg 设为 none 并把签名去掉;以及拿某个服务的 RSA 公钥当作 HMAC 密钥,从而骗一个 RS256 的验证方去跑 HS256
  3. 检查 exp,以及 nbf(如果存在),并允许一点点时钟偏差。
  4. 检查 issaud 与这个服务的预期相符。

只有到那时,那些声明才有意义。在那之前,载荷只是一个经由网络送达的、未经验证的字符串。

一些实用提示

  • 令牌通常以 Authorization: Bearer <token> 的形式放在 HTTP 头里发送,所以载荷要保持小。你每添加一个声明,每一次请求都要把它带上。
  • 一个签名过的令牌在过期之前一直有效。没有内建的办法去吊销它,这也是访问令牌的有效期往往很短(常常是几分钟)、并配一个单独的刷新令牌来换取新令牌的原因。
  • 如果一个令牌看起来格式不对,先数句点。两个句点、三个部分,就是一个正常的签名 JWT。从终端里粘出来时被截断的令牌,是“签名无效”极其常见的成因。
  • 调试时解码一个令牌来查看它的过期时间、主体或角色,是完全安全的,也不需要任何机密。载荷的用途正是如此。