JSON Web Token 无处不在——登录会话、API 鉴权、单点登录。但出人意料的是,很多开发者把它当成某种 opaque 的魔法。它不魔法。JWT 就是三段用点连起来的 Base64url 文本,把它解出来,就没什么好琢磨的了。
三段结构
一个 JWT 长这样:xxxxx.yyyyy.zzzzz。每段用点分开:
- header(头部)——关于这个令牌的元信息:用哪种签名算法、用哪个密钥 id。
- payload(载荷)——声明(claims):这个用户是谁、什么时候过期,以及你想放的任何东西。
- signature(签名)——对前两段的密码学签名。
把 header 解出来,是类似这样的 JSON:
{
"alg": "HS256",
"typ": "JWT",
"kid": "key-2026-09"
}
payload 里到底装了什么
payload 是你真正关心的那段。一个典型的已解码 payload:
{
"sub": "user_8821",
"name": "Ada Lovelace",
"role": "admin",
"exp": 1785000000
}
这些字段叫做声明(claims)。其中有几个是预留了特殊含义的:
| 声明 | 含义 |
|---|---|
sub | 主体(subject)——令牌是关于谁的,通常是用户 id |
exp | 过期时间——过了这个时间点令牌作废(Unix 秒) |
iat | 签发时间——令牌什么时候创建的 |
nbf | 生效时间——在此之前令牌无效 |
iss | 签发者——谁发的这个令牌 |
aud | 受众——谁被允许接受它 |
payload 谁都能读
这是新手最容易搞错的一点。header 和 payload 做的是 Base64url 编码,不是加密。这里没有密钥参与。任何拿到令牌的人,在浏览器控制台里两行就能解出来:
const [, p] = token.split('.');
JSON.parse(atob(p.replace(/-/g,'+').replace(/_/g,'/')));
所以 payload 是一本敞开的书。永远别把密码、信用卡号或者任何敏感信息放进 JWT 的 payload。这个编码的存在只是为了让结构化数据能塞进一个文本令牌,不是为了藏东西。
签名是干什么的
签名是唯一涉及密钥或私钥的那一段。它是针对 header 和 payload 算出来的:
signature = sign(base64url(header) + "." + base64url(payload), secretOrPrivateKey)
它的作用有两层:证明令牌确实出自持有密钥的一方,以及证明 header 和 payload 在传输途中没被改过。如果攻击者把 "role":"user" 改成 "role":"admin",签名就对不上了,服务端会拒绝。
关键点在于:签名提供的是完整性与真实性,不是机密性。它阻止篡改,但不阻止阅读。
HS256 与 RS256 的区别
| 算法 | 密钥 | 验证方式 |
|---|---|---|
| HS256 | 一个共享密钥 | 签发和验证都用同一把密钥 |
| RS256 | 公钥/私钥对 | 认证服务用私钥签,其他服务用公钥验证 |
HS256 更简单——一把密钥,既能签也能验。缺点是任何能验证的人也能伪造,因为他们拿着同一把密钥。RS256 把角色拆开了:认证服务守住私钥,其余每个服务用一把它无法用来签名的公钥去验证。对于"多个服务信任同一个签发方"的场景,RS256 扩展起来更顺。
为什么 JWT 不适合当会话存储
JWT 是无状态的——服务端不需要会话表,因为一切都在令牌里。听起来是个优势,但有个尖锐的副作用:你很难在令牌过期前把它吊销。让用户登出,而他手里那个仍然有效的令牌,会一直能用到 exp 到期。要补这个洞,你最后还是得建一张吊销列表——把原本想省掉的那个状态又加回来了。
对于短生命周期的 API 访问令牌,无状态没问题。但"记住我、在这台设备上待两周"这种需求,服务端会话或者 refresh token 流程通常是更合适的工具。
什么时候不该用 JWT
- 用户一登出你就必须让令牌立刻失效——用会话。
- payload 必须保密——JWT 的 payload 天生可读。
- 你只是想要一个随机的会话 id——直接用不透明的随机令牌,更简单也更短。
安全地调试一个 JWT
永远不要把生产环境的真实令牌粘进某个来路不明的第三方网站。用你信得过的、在本地运行的客户端解码器。本站这个 JWT 解码器 完全跑在你的浏览器里,令牌不会离开你的机器。粘进去,读一下 header 和 payload,确认 exp 和 aud 符合预期即可。