Skip to main content

JWT 与无状态认证

JWT 的价值是「服务端不存会话即可验证身份」。代价是:签发后无法主动失效,这是所有设计的起点。

一、三段结构各自装什么​

header算法与类型payload只是 base64,不是加密signature防篡改,不防偷看payload 里的内容任何人都能解开,所以不能放密码、身份证、密钥。「无状态」的真实含义与失效代价:签发即有效到期前无法直接作废短 access + 长 refresh用有效期换可控性黑名单 / 版本号重新引入状态选哪一种取决于能不能接受「签发后在有效期内一定有效」这个前提。
图: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 取对应公钥——这样轮换密钥不需要重启服务。

二、有状态与无状态的取舍​

本质区别只有一条:状态放在客户端还是服务端。

由此推出各自的取舍:

维度SessionJWT
服务端状态需要存储不存
横向扩展需要共享存储或粘性路由天然友好
主动失效删掉会话即可做不到,需额外设计
泄露影响服务端可即时止损在有效期内一直有效
每次请求要查一次存储只验签

「无状态」不是升级,是取舍:换来扩展性,交出控制权。所以短有效期不是可选项,而是这套方案的地基。

三、双 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),要能容忍一次并发导致的失败,否则用户会被莫名登出。
浏览器业务接口认证服务access 有效200 返回access 过期401带 refresh 换新 access新 access + 新 refresh(轮换)重放原请求并发刷新:多个 401 同时在途时必须排队,只刷新一次,其余等新 token 后重放
图:无感刷新的时序——401 触发刷新、refresh 轮换、原请求重放,并发必须去重

四、两种存放位置的取舍​

三种方式,取舍清晰:

  • localStorage —— 最方便,但一旦有 XSS 就被整个读走;
  • HttpOnly + Secure Cookie —— 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 的固有代价,应对手段有四条,通常组合使用:

  1. 短有效期 —— access token 有效期越短,泄露后的影响窗口越小;
  2. 黑名单 —— 把需要失效的 token 记进一个短期的拒绝列表,代价是又引入了存储(但只需覆盖有效期内的一小段);
  3. 版本号 —— 用户表存一个 token 版本, token 里带上它,改密码或封号时递增版本号,所有旧 token 自然失效;
  4. 关键操作二次校验 —— 改密码、改支付信息这类动作再做一次验证,不单靠 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 能做什么」的争论看完原文就不存在了。