JSON Web Tokens
不透明令牌需要查表:字符串本身没有任何含义,所以服务器必须去问自己的存储,这串字符到底属于谁。
去掉查表这一步,就得有别的东西来顶替它。JSON Web Token(简称 JWT)的做法,就是把答案直接放进令牌本身。
三段式,用点分隔的一整串字符
JWT 看起来像一堆乱码,但其实结构非常严格。它由三部分组成,用点号连接:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjQ4MjEsInJvbGUiOiJ0ZWFjaGVyIn0.4pcPyMD09olPSyXnrXCjTwXyr4BsezdI1AVTmud2fU4
└────────── header ──────────┘ └────────── payload ─────────┘ └────────── signature ──────────┘- Header(头部)。 说明用的是哪种算法签名,以及标明这是一个 JWT。
- Payload(负载)。 身份相关的信息,称为claims(声明):用户是谁、拥有什么角色、令牌何时过期、由谁签发。
- Signature(签名)。 一种密码学印章,证明内容没有被篡改过。
服务器生成 JWT 的方式是:拿到 header 和 payload,与只有自己知道的密钥结合,算出签名。然后把这三部分统一编码、拼接在一起。
任何人都能读懂一个 JWT。但只有掌握密钥的人,才能做出服务器会认可的那种。
正因如此,它不用查表也能被信任——这一句话就是它的核心思路。
也就是说,印章并不会隐藏信里的内容。它证明的是谁封的信,以及信自封好之后没人拆开过。
整个流程,以及变化在哪
- 用户用用户名和密码登录。
- 服务器验证这些信息。
- 验证通过后,服务器创建一个 JWT 并签名。
- 令牌发给客户端,由客户端保存。
- 之后的每次请求都通过
Authorization请求头携带它,和不透明令牌的用法一模一样。 - 服务器验证签名并读取其中的 claims。不需要查表,也不需要存储。
第六步是与不透明持有者令牌唯一不同的地方,而这一点彻底改变了整个模型:
| 不透明持有者令牌 | JWT | |
|---|---|---|
| 内容 | 什么都没有,只是一个随机字符串 | 编码后的 claims:用户 id、角色、过期时间 |
| 识别用户的方式 | 查表 | 验证签名,读取 claims |
| 服务器存储 | 一张小的令牌-用户对照表 | 无 |
| 身份信息存放位置 | 服务器上 | 令牌内部 |
不透明令牌只是指向保存在服务器上的身份信息。JWT 则是直接携带身份信息。
查表意味着服务器每次都能拿到最新的答案。而读取令牌,得到的只是令牌创建那一刻成立的答案。
四种出问题的方式
JWT 本质上仍是一种持有者令牌,所以所有不透明令牌的陷阱都同样适用:偷到它,你就是那个用户。而它"自包含"这个特性,又额外带来了四个陷阱。
| 出了什么问题 | 为什么重要 | 解决办法 | |
|---|---|---|---|
| 1 | 以为它是加密的 | 它只是编码,不是加密。任何拿到它的人都能读出 payload 内容。对应 STRIDE 中的信息泄露,OWASP 中的敏感数据暴露 | payload 里不要放任何你不愿意写在明信片上的内容 |
| 2 | 密钥太弱或泄露 | 猜出或偷到密钥,攻击者就能签发自己的令牌,包括一个自称是 admin 的令牌。对应权限提升 | 使用足够长的随机密钥,从环境变量加载,绝不提交到代码库 |
| 3 | payload 塞得太满 | 令牌大意味着每个请求的请求头都大:请求变慢、代理不堪重负、日志臃肿。而且里面塞的一切,一旦令牌泄露也会跟着泄露 | 只放服务器每次请求都需要的内容 |
| 4 | 没有设置过期时间 | JWT 是没法撤销的,没有过期时间就意味着它永远有效。对应身份验证机制失效 | 一定要设置过期时间,而且要短 |
陷阱 2 是那种能把小失误变成彻底沦陷的问题。一个泄露的会话 id 只牵连一个账号;而一个泄露的签名密钥,牵连的是所有账号,包括还没创建的账号。
但它并不是被打乱了。它只是用一种不方便肉眼阅读的"字母表"写成的,任何解码工具都能立刻把它还原成明文。
动手试一试
一次无状态安全审计,四个场景。
// 1. 令牌查找表中的一条记录
const tokenTable = [
{ token: 'a91f...', userId: 4821, expires: '3000-01-01T00:00:00Z' },
]
// 2. 每次请求都会调用
function authenticate(token) {
const record = tokenTable.find((r) => r.token === token)
return record ?? null
}
// 3. 放进某个 JWT 的 payload
{ id: 4821, name: '思远', admin: false, streetAddress: '朝阳门内大街14号',
phone: '+86 138 0013 8000', tabSwitches: 14, windowResizes: 3 }
// 4. 签发 JWT 时使用的服务器配置
const config = { secret: 'secret123', algorithm: 'HS256' }对照一下你的答案
| # | 陷阱 | 解决办法 |
|---|---|---|
| 1 | 令牌有效期过长。要到公元 3000 年才过期,几乎可以肯定是笔误,而且这意味着它会作为一个有效凭证持续存在整整一千年 | 把有效期设成 30 分钟 |
| 2 | 无法撤销。这段代码只是找到记录就直接返回,从来没检查过 expires,所以过期的令牌照样能通过验证 | 拿 expires 和当前时间比较,过期了就返回 null,顺便把这一行数据删掉 |
| 3 | payload 塞得太满,造成信息泄露。街道地址和电话号码放在了任何持有者都能读到的地方,旁边还塞了些服务器根本用不上的统计数据 | 只留 id、name 和 admin,别的都不要 |
| 4 | 密钥太弱,而且没设过期时间。secret123 很容易被猜到,还被硬编码在代码里,也就是说它已经进了代码库 | 从环境变量里取一个足够长的随机密钥,再加上过期时间的声明 |
场景 2 是最难被发现的一个。这个函数看上去没问题,它确实能找到令牌,对不存在的令牌也确实会返回 null。但存了过期时间却从来不检查,效果跟根本没设过期时间没有任何区别。
接下来往哪走
两种无状态模型,一个是用查表换来了可撤销性,另一个是用可撤销性换来了扩展性。但不管哪一种,你的应用依然要自己负责密码、重置流程,以及证明"某人到底是谁"的整套机制。
OAuth 与委托身份要问的是:这份责任,你到底想不想自己扛。

