阅读提要

JWT 解决的是「跨服务信任」,不是「记住谁登录了」。把它当会话用,就会遇到「无法登出」「无法封禁」这两个死结。

开始阅读

字号

一个常见的设计

登录成功后签发一个 7 天有效的 JWT,前端存 localStorage,每次请求带上。服务端验签通过就认为用户已登录。

这个设计能跑,但会遇到两个绕不过去的问题。

问题一:用户点了「退出登录」,但 token 还在有效期内。 前端删掉本地存储只是「不带了」,任何人拿这个 token 依然能访问。如果 token 泄露过,7 天内都有效。

问题二:管理员封禁了一个账号。 服务端没有任何地方可以「让这个 token 失效」——验签只检查签名和过期时间,不查库。封禁要等 7 天才生效。

这两个问题的根源相同:JWT 是无状态凭证,无状态就无法撤销。

无状态凭证的代价

JWT 到底解决什么问题

它的设计目标是:服务 A 签发,服务 B 能独立验证,不需要每次回源问 A。

典型场景是微服务之间、或者第三方授权(OAuth 的 access token)。这些场景的共同点是:验证方和签发方不在同一个进程里,且不共享数据库。

而「用户登录状态」的场景恰好相反:签发方和验证方是同一个服务,共享同一个数据库。用无状态方案解决一个有状态的业务问题,是自己给自己找麻烦。

那为什么大家都在用

因为它在早期看起来很方便:不用建 session 表,不用查库,天然支持水平扩展。

但「不用查库」这个优势,在你有 Redis 之后就不成立了——查 Redis 比验签多几毫秒,换来可撤销能力。

而水平扩展的问题,用共享 session 存储(Redis)同样能解决。

正确做法:会话用不透明 token

// 签发:随机串,不是 JWT
const token = crypto.randomBytes(32).toString('base64url')
await redis.set(`session:${token}`, JSON.stringify({ userId, role }), 'EX', 60 * 60 * 24 * 7)

校验时查 Redis。看起来多了一次 I/O,但换来三件事:

  1. 可撤销:退出登录直接 del;
  2. 可封禁:封号时删掉该用户所有 session;
  3. 可续期:滑动过期,活跃用户不会突然掉线。

而且 token 本身不含任何信息,泄露了也解析不出用户 ID。

如果一定要用 JWT

有一种折中:短有效期 JWT + 刷新令牌。

  • access token 有效期 15 分钟,无状态,验签即可;
  • refresh token 存在服务端(数据库或 Redis),有效期 30 天,可以撤销;
  • access token 过期后,用 refresh token 换新的。

这样「封禁生效延迟」最长 15 分钟,多数业务可以接受。代价是实现复杂度上升,要处理刷新失败、并发刷新、刷新令牌轮换等问题。

注意:access token 越短,无状态的收益越小。15 分钟的窗口里,查一次 Redis 的成本几乎可以忽略——这又回到「为什么不直接用 session」。

存储位置:别放 localStorage

无论用哪种 token,都不要放 localStorage。

localStorage 可以被同源的任何 JS 读取。一次 XSS 就能把 token 偷走,而且攻击者可以长期使用。

正确位置是 httpOnly + Secure + SameSite=Lax 的 Cookie:

res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 7 * 24 * 3600 * 1000,
  path: '/'
})

httpOnly 让 JS 读不到,XSS 拿不走。SameSite=Lax 挡住大部分 CSRF,配合「写操作校验 Origin 头」基本够用。

代价是跨域场景麻烦(需要 SameSite=None; Secure + CORS 配置),但比起 token 被偷,这点麻烦值得。

一个具体的判断清单

设计登录态时问四个问题:

  1. 需要「立即退出登录」吗?需要 → 不能纯 JWT。
  2. 需要「封禁账号立即生效」吗?需要 → 不能纯 JWT。
  3. 有多个服务需要独立验证身份吗?有 → JWT 有价值。
  4. 有共享存储(Redis)吗?有 → 不透明 token 更简单。

四个问题答完,方案基本就定了。多数单体应用的正确答案是:不透明 token + Redis。

收尾

JWT 是一个好工具,但它解决的问题很具体。把它当成「现代的 session」,就会陷入「为什么退出登录不管用」这种反复出现的困惑。

一句话记住:能撤销的才叫会话,不能撤销的只是通行证。

一个中间方案:服务端会话 + 客户端凭证分离

如果既想要 JWT 的无状态校验,又想要会话的可撤销,可以拆成两层:

  • 短期 access token(5 分钟)用于服务间调用,纯验签;
  • 长期 refresh token 存数据库,只有换发时才查。

代价是前端要处理「token 过期 → 自动刷新 → 重放原请求」这套逻辑,而且要处理并发刷新(多个请求同时发现过期,只应刷新一次)。

实现复杂度不低。如果只有一个服务,直接上 Redis 会话更省事——这也是多数项目的实际选择。

你认为这篇文章怎么样?

还没有表情反馈

分享文章