引言 — 「JWT 扩展性好」这句话的实际含义
决定认证方式时最常出现的一句话是:「会话把状态放在服务器上,所以扩展不了;JWT 是无状态的,所以扩展性好。」这句话只对一半。而错的那一半,在实务里代价高得多。
先做事实核对。JWT 并没有被加密。它只是做了 base64url 编码,谁都能读。
echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
| base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}
签名保证的是不可伪造,而不是保密。所以把邮箱、电话号码、内部标识符放进载荷,等同于把个人信息明文存进浏览器本地存储。除非用 JWE 加密,否则应当把载荷当作公开数据。
本文讨论的是在两种方式之间做选择的标准。先把结论说在前面:面向浏览器的大多数 Web 应用用会话 Cookie 更好,而 JWT 用在服务间通信以及真正需要无状态的位置。
根本区别只有一个 — 状态放在哪里
会话方式里,服务器在会话存储中持有认证状态,客户端只用 Cookie 拿着一个没有含义的标识符。请求到来时,服务器用这个标识符去查存储。查不到就是认证失败。
JWT 方式里,服务器什么都不持有,客户端持有的是已签名的声明本身。请求到来时,服务器校验签名并确认过期时间。没有存储查询。
一切都从这里分岔。会话的认证判断依据在服务器手里,所以服务器随时可以改。JWT 的依据已经交到客户端手上,服务器收不回来。性能和存放位置都只是这个差别的结果。
| 项目 | 会话 Cookie | JWT (无状态) | 短期访问令牌 + 刷新令牌 |
|---|---|---|---|
| 状态位置 | 服务器存储 | 客户端 | 客户端 + 用于刷新的服务器存储 |
| 每请求成本 | 1 次存储查询 (以 Redis 计不到 1ms) | 签名校验 (HMAC 是微秒级) | 签名校验。只在每个刷新周期查一次存储 |
| 即时失效 | 可以。删除记录即可 | 做不到。到期前一直有效 | 延迟等于访问令牌寿命 (通常 5 到 15 分钟) |
| 权限变更生效 | 立即 | 到期之后 | 刷新时刻 |
| 大小 | 32 字节上下的标识符 | 300 到 1000 字节。每次请求都要发送 | 与访问令牌大小相同 |
| 主要暴露路径 | CSRF | XSS (取决于存放位置) | 取决于存放设计 |
| 多节点扩展 | 需要共享存储 | 不需要共享存储 | 只有刷新路径需要共享存储 |
| 能校验的主体 | 服务器自己 | 任何持有密钥或公钥的人 | 访问令牌人人可校验,刷新只有签发方可以 |
表格的最后一行才是 JWT 真正的存在理由。会话标识符只有能访问存储的主体才能解释,而 JWT 只要有公钥谁都能校验。当签发方与校验方分属不同组织或不同系统时,这个性质无可替代。
JWT 最大的弱点 — 无法即时失效
假设账号被盗,用户改了密码。如果是会话,把该用户的会话记录全部删掉就完事了。从下一个请求起立刻登出。
如果是 JWT,没有可删的对象。攻击者手里的令牌一直有效到过期时刻。要是把过期时间设成 24 小时,攻击者最多可以保持 24 小时的登录状态。收回管理员权限、停用账号、拦截支付,都一样。因为服务器校验的只有签名和 exp。
现实的应对是三样东西的组合。
第一,把访问令牌的寿命设短。5 到 15 分钟是常见值。这是把暴露窗口缩小相应幅度的办法。
第二,用刷新令牌来续期。刷新令牌记录在服务器存储里,续期时确认其有效性。同时配合轮换。用过一次的刷新令牌立即作废并签发新的,如果已经用过的令牌又送了进来,就视为被盗,把整个家族一并作废。
async function rotateRefreshToken(presented) {
const record = await db.refreshTokens.findByHash(sha256(presented))
if (!record) throw new AuthError('unknown token')
if (record.usedAt) {
// 已经用过的令牌又来了 = 有副本在外面流传
await db.refreshTokens.revokeFamily(record.familyId)
throw new AuthError('reuse detected, family revoked')
}
await db.refreshTokens.markUsed(record.id)
return issuePair({ userId: record.userId, familyId: record.familyId })
}
第三,设一个黑名单。把已失效令牌的 jti 存起来,每个请求都比对一次。
不过这里有一点必须老实指出。一旦加上黑名单,所有请求都会去查共享存储,而那和会话是完全相同的结构。存储故障变成认证故障是一样的,每个节点都要连上存储也是一样的。不同之处只有:比会话多了一堆零件,而且令牌更大。这就成了一个抹掉无状态优点、只留下复杂度的选择。
所以真正可用的中间地带,是短期访问令牌与已存储的刷新令牌的组合。它把存储查询从每个请求降到每个刷新周期一次,同时把失效延迟约束在访问令牌寿命之内。选这个设计时,首先要回答的是这个延迟在业务上能不能承受。如果是像金融交易权限那样需要以秒计收回的位置,那就承受不了。
令牌该存在哪里
这个问题常被归结为「XSS 和 CSRF 你认哪一个」,但准确的比较稍有不同。
放进 localStorage,同源的所有脚本都能读。只要发生一次 XSS,令牌就被整个发往攻击者的服务器,攻击者可以拿它在浏览器之外自由调用 API。它一直有效到过期,用户登出也没用。一次 XSS 就等于凭据泄露。
放进 httpOnly Cookie,脚本读不到它的值。就算出了 XSS,令牌本身也不会流出去。不过攻击者的脚本在那个页面里发请求时 Cookie 会自动带上,所以它仍然可以代替用户做事。这是明确的损害,但和凭据泄露之后在任意位置被无限期使用相比,损害规模不同。攻击窗口被限制在那个会话和那个页面里。
用 Cookie 就得处理 CSRF。属性设置是第一道防御。
Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
SameSite=Lax不会把 Cookie 带到跨站 POST 上,只会带到顶层导航的 GET 上。Chromium 把它定为默认值之后,基于表单的 CSRF 有相当一部分被自动挡住了。Strict连从外部链接进来的 GET 也不带 Cookie,因此看上去像是登录状态掉了。None允许跨站发送并且必须配 Secure,这种情况下 CSRF 令牌是必需的。
这里有一个重要的陷阱。SameSite 是按站点而不是按源来判断的。app.example.com 和 api.example.com 是不同的源,却是同一个站点。只要有一个子域出现 XSS,SameSite 就一点防御也提供不了。如果服务把用户内容托管在子域上,风险尤其大。
SPA 里被广泛采用的折中方案,是把访问令牌只放在 JavaScript 的内存变量里,并把刷新令牌作为 httpOnly Cookie 只作用于刷新路径。
// 访问令牌只放在模块作用域变量里。刷新页面就会消失
let accessToken = null
async function refresh() {
// 刷新 Cookie 被限定在 Path=/auth/refresh,只会带在这个请求上
const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
if (!res.ok) throw new AuthError('refresh failed')
accessToken = (await res.json()).access_token
}
代价是每次刷新页面多发一次续期请求,换来的是:即便出了 XSS,可持久使用的凭据也不会落到脚本手里。
补充一句,在已经发生 XSS 的情形下,不存在完全的防御。存放位置之争是在谈减轻损害,不是在谈阻止损害。Content-Security-Policy 和输出转义才是根本对策,存放位置排在其后。
实现漏洞 — 签名只有在被校验时才有意义
JWT 的规范很灵活,库的用法只要稍微偏一点,签名就形同虚设。以下是已经成为经典的几个案例。
曾经有一批库,把alg头改成 none 发过去就能不带签名通过。攻击者只需把载荷里的 role 改成 admin、把签名部分留空就行了。听上去像陈年旧事,但在校验时从令牌头里读取算法的代码,如今仍然会出现。
算法混淆更狡猾。如果签发 RS256 的服务器在校验函数里没有固定算法,攻击者就把头改成 HS256,把服务器的公钥当作 HMAC 密钥来签出一个令牌。公钥顾名思义是公开的,所以谁都能造。
最常见的还是干脆不校验这个错误。
// 不要这么做 — decode 不会校验签名
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// 校验时由服务器固定算法、签发者和受众
import { jwtVerify } from 'jose'
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // 不让令牌来挑
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 5,
})
不校验aud的话,为别的服务签发的令牌会在我们的 API 上通过。当多个内部服务共用同一台认证服务器时,这是真实会发生的事。不校验iss属于同一性质的问题。
用 HMAC 时如果密钥太短,会被离线暴力破解。把字典单词或默认值原样留着的案例,在公开服务里也能找到。要用至少 256 位的随机值,并且放在密钥管理系统里,而不是代码或仓库里。
用kid头来找密钥的实现,不能忘了这个值是攻击者的输入。直接塞进文件路径或 SQL 查询就会产生路径穿越和注入。只有在与已知密钥标识符集合比对之后才可以使用。
会话真正成为扩展问题的时点
我们用数字来核对一下「会话不利于扩展」这个通行看法。
一条会话记录由用户标识符、签发时间、过期时间和少量元数据组成。往宽了算是 200 到 500 字节。100 万个并发会话就是 200MB 到 500MB。这是单个 Redis 实例能从容承受的规模。就算是 1000 万个也不过几 GB,而到了这个体量的服务,本来就已经在运营 Redis 集群了。
查询成本是对单个键的 O(1) 查询。同区域内的 Redis 往返通常不到 1ms,单实例每秒能处理数万到十万量级的命令。和应用在每个请求里执行的其他数据库查询相比,占比可以忽略。
那么会话真正成为问题的地方在哪里?有三处。
把会话放在进程内存里、再用会话粘滞把客户端钉住的情况。每次发布都会掉登录,节点之间的负载也均衡不了。这不是会话的问题,而是存储选型的问题,迁到共享存储就能解决。
有多个区域的情况。欧洲用户的请求为了查会话要往返到美国区域,就会多出几百毫秒。这时需要分区域的存储和复制策略。从这个点开始,无状态令牌才具备实质优势。
校验方不是我们自己的情况。合作方需要校验我们的令牌,而我们不可能把自己的会话存储开放给他们。公钥校验就是答案。
归纳起来,会话变得不利于扩展的时点,比大多数团队想象的要晚得多。在那个时点到来之前就选 JWT,等于没换来扩展性,却先背上了无法失效这笔债。
实务建议 — 何时用哪个
如果是运行在同一域名下的 Web 应用,会话 Cookie 就是默认选项。加上 httpOnly、Secure、SameSite=Lax,存储用 Redis 或数据库。登出和权限收回立刻生效,零件少,也不用担心令牌大小。需要跨子域共享时,用 Domain 属性即可解决。
如果是有移动应用或第三方客户端接入的自有 API,就用短期访问令牌加轮换的刷新令牌。这种环境用不了 Cookie,所以需要令牌,而把状态放在刷新路径上就拿到了失效手段。刷新令牌要放进操作系统的安全存储里。
服务间通信很适合 JWT。每个调用方不必往返认证服务器,用公钥就能校验,把作用域收窄、签发得足够短,失效问题实质上就消失了。如果寿命是几十秒量级,几乎不存在需要作废的理由。
使用外部身份提供方时,OIDC 的 ID 令牌就是 JWT。但 ID 令牌是用来证明身份的,不是 API 访问凭据。直接拿 ID 令牌做 API 认证是常见的误用。
最后有一个必须避开的组合:把过期时间很长的 JWT 放进 localStorage,登出则处理成在客户端把值删掉。登出按钮什么也没失效掉,一次 XSS 就能把长期凭据整个卷走。它既是流传最广的教程写法,也是最脆弱的配置。
结语 — 先确认无状态是不是免费的
选 JWT 时该问的不是「扩展会不会顺利」,而是「这份凭据收不回来也没关系吗」。没关系,那就能完整享受无状态的好处。有关系,那就得以某种形式把状态重新请回来,而在那一刻,相对会话的优势基本就消失了。
认证设计的第一个决定不是令牌格式,而是失效需求,是它决定了状态该放在哪里。面向浏览器的普通 Web 服务,从会话 Cookie 起步几乎总是对的。等到以后真的需要无状态时再迁移的成本,比一开始就用 JWT、然后经历一次失效事故的成本要便宜。
현재 단락 (1/90)
决定认证方式时最常出现的一句话是:「会话把状态放在服务器上,所以扩展不了;JWT 是无状态的,所以扩展性好。」这句话只对一半。而错的那一半,在实务里代价高得多。