JWT 与无状态认证
JWT 的价值是「服务端不存会话即可验证身份」。代价是:签发后无法主动失效,这是所有设计的起点。
一、三段结构各自装什么
JWT 由三段 Base64URL 编码拼接而成:头部.载荷.签名。
- 头部 —— 算法与类型(
alg、typ); - 载荷 —— 各种声明(claims),比如签发者、过期时间、用户标识;
- 签名 —— 对前两段的签名,用于防篡改。
最关键也最常被误解的一点:前两段只是编码,不是加密。 任何人拿到 token 都能解出载荷内容。签名保证的是「没被改过」,不保证「没被看到」。所以载荷里不能放任何敏感信息——密码、身份证号、内部标识都不行。
// 前两段只是 Base64URL 编码,任何拿到 token 的人都能解出载荷
const [, payload] = token.split('.')
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// → { sub: 'user-1', exp: 1700000000 } 签名只保证没被改,不保证没被读
顺带一条实践建议:不要自己实现签发与校验,用成熟库,并严格校验 alg。历史上出现过把算法改成 none、或用非对称算法的公钥当对称密钥用的漏洞,根源都是「信任了 token 里声明的算法」。
校验不能只看 exp。标准声明里还有几个必须一起查:iss(签发方是不是你信任的那一方)、aud(这份令牌是不是发给你的服务)、nbf(生效时间)、jti(唯一标识,配合黑名单使用)。漏校验 aud / iss 是 JWT 最典型的事故来源——别的签发方发的令牌、或同一签发方发给别家服务的令牌,都可能被你的接口当成合法凭证收下。
算法上也要收紧:只接受实际使用的那一种(写死白名单),并通过 kid 从 JWKS 取对应公钥——这样轮换密钥不需要重启服务。
二、有状态与无状态的取舍
本质区别只有一条:状态放在客户端还是服务端。
由此推出各自的取舍:
| 维度 | Session | JWT |
|---|---|---|
| 服务端状态 | 需要存储 | 不存 |
| 横向扩展 | 需要共享存储或粘性路由 | 天然友好 |
| 主动失效 | 删掉会话即可 | 做不到,需额外设计 |
| 泄露影响 | 服务端可即时止损 | 在有效期内一直有效 |
| 每次请求 | 要查一次存储 | 只验签 |
「无状态」不是升级,是取舍:换来扩展性,交出控制权。所以短有效期不是可选项,而是这套方案的地基。
三、双 token 的刷新时机
标准组合是短期 access token + 长期 refresh token:access 用于日常请求,过期后用 refresh 换一份新的,用户无感知。
最容易写错的一处是并发刷新:页面同时发出五个请求,五个都发现 token 过期,于是同时发起五次刷新。结果要么刷新被打爆,要么因为 refresh token 轮换导致后四次全部失败。
正确做法是把刷新合并成一个进行中的 Promise:
let refreshing = null
async function getAccessToken() {
if (!isExpired(accessToken)) return accessToken
refreshing ??= doRefresh().finally(() => { refreshing = null })
return refreshing // 所有并发请求共享同一次刷新
}
另外三个细节:
- 刷新失败要直接登出,不要无限重试;
- 刷新期间到达的请求应当排队等待而不是带着过期 token 发出去;
- 如果用 refresh token 轮换(每次刷新返回新的 refresh token),要能容忍一次并发导致的失败,否则用户会被莫名登出。
四、两种存放位置的取舍
三种方式,取舍清晰:
localStorage—— 最方便,但一旦有 XSS 就被整个读走;HttpOnly+SecureCookie —— JS 读不到,能防 XSS 窃取;代价是要额外防 CSRF;- 内存 —— 最安全,刷新页面就失效,需要配合静默刷新机制。
选哪个取决于你能把 XSS 与 CSRF 各自防到什么程度。当前(截至 2026 年 10 月)浏览器应用更被推荐的模式是 BFF:
BFF(Backend For Frontend) 指的是:浏览器只持有一个 HttpOnly 的会话 Cookie,真正的 access/refresh token 由服务端持有并用于调用下游接口。这样即使页面存在 XSS,攻击者也拿不到可用于调用 API 的 token。
这里有个容易混淆的点:同样是「后端参与」,Token-Mediating Backend(后端拿到 token 后转发给前端存着)和真正的 BFF(token 始终留在服务端)安全性完全不同,前者只是把风险挪了个位置。
如果选择 Cookie 方案,三个属性要同时设对:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
SameSite=None 必须搭配 Secure,否则浏览器会直接拒绝写入——而且是静默失败,排查时很容易卡很久。
读过期时间只要解出载荷即可(校验仍应交给成熟库,别自己实现签名验证):
function peek(token) {
const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(b64))
}
const { exp } = peek(token)
if (Date.now() > exp * 1000 - 30_000) {
await refresh() // 提前 30 秒刷新,避免请求发出时才过期
}
五、签发后怎么提前失效
「签发后收不回」是 JWT 的固有代价,应对手段有四条,通常组合使用:
- 短有效期 —— access token 有效期越短,泄露后的影响窗口越小;
- 黑名单 —— 把需要失效的 token 记进一个短期的拒绝列表,代价是又引入了存储(但只需覆盖有效期内的一小段);
- 版本号 —— 用户表存一个 token 版本, token 里带上它,改密码或封号时递增版本号,所有旧 token 自然失效;
- 关键操作二次校验 —— 改密码、改支付信息这类动作再做一次验证,不单靠 token。
没有一种方式是「完美无状态」的,都是在「保留大部分无状态收益」和「能控制风险」之间取平衡。
想让已签发的令牌提前失效,就得在服务端留一份状态,最省事的是版本号:
// 签发时带上用户当前的令牌版本
const token = sign({ sub: userId, tv: user.tokenVersion }, secret)
// 校验时比对,改一次 tokenVersion 就让该用户所有令牌失效
const payload = verify(token, secret)
if (payload.tv !== (await getUser(payload.sub)).tokenVersion) {
throw new Error('令牌已失效')
}
// 代价是每次校验都要查一次库——这就是「无状态」换不来的东西
六、载荷里不该放什么
JWT 的载荷只是编码,任何人都能解出来,只能放非敏感标识,不能当加密容器用。
无状态也不等于更好,代价是无法主动失效——注销与封号都要额外设计。
token 有效期不是越长越省事,泄露后的影响窗口会同步变长,短期 token 配合刷新才是标准做法。
并发刷新也不能不管:不合并会同时发起多次刷新,配合轮换机制时会直接把用户登出。
JWT 换来的是扩展性,代价是「发出去就收不回」——所以有效期一定要短,失效机制一定要额外做。
JWT 的结构与声明字段见 RFC 7519,很多「JWT 能做什么」的争论看完原文就不存在了。