微服务

JWT 的无状态困境:签发之后为什么收不回来

JWT 的全部优势来自服务端不存状态,代价也正来自这里——签发之后没有任何入口能撤回它。这篇文章用一个「幽灵窗口」实验讲清无状态与可撤销的矛盾,并给出四种撤销方案的取舍。

2026-02-08 约 12 分钟 4601 字 JWT 鉴权 微服务 安全

一、一个被忽略的安全窗口

先看一段真实的时序。用户登录后拿到了 token,两小时后点了「退出登录」:

T+0       用户登录,签发 token(有效期到 T+7200,即 2 小时)
T+1800    用户在手机上点了「退出登录」

纯 JWT 方案下,退出后 1 秒用这个 token 访问 -> 依然有效
★ 幽灵窗口:还有 5399 秒(90.0 分钟)

用户以为自己退出了,服务端却完全不知道。

更严重的第二个场景:

用户在 T+1800 改了密码,T+1900 用旧 token 访问 -> 依然有效
原因:token 里没有密码版本这个信息,服务端验证时压根不看密码

这不是配置失误,而是 JWT 的机制决定的。

二、核心矛盾:无状态 = 无法撤销

JWT 的全部优势来自服务端不存状态——网关验个签名就能放行。

也正因为不存状态,签发之后就没有任何办法撤回它。

这两件事是同一枚硬币的两面,理解了这一句,后面所有方案都是它的推论。

用门禁卡理解 JWT

卡片上的要素 对应 token 的
「张三」 payload 里的 uid / name
「有效期到 12:00」 exp
公司的防伪钢印 Signature
卡号 jti

保安(网关)怎么查验? 只看两件事:

  1. 钢印是不是真的 → 验签名
  2. 时间过没过 → 看 exp

然后就放行。保安不会拿起电话去总部查「这张卡还在有效名单里吗」。

这就是「无状态」:服务端不存任何东西,看卡面就能判断。

那「退出登录」做了什么? 只是把手机里存的 token 删掉——相当于张三把自己的卡扔了:

  • 张三自己进不去 ✓
  • 但卡本身没被作废
  • 如果卡之前被复印过(token 被截获),复印件上的钢印照样是真的,照样能刷 ✗

问题的根子:张三想让卡作废,但总部根本没有「作废卡」这个功能——因为总部压根没存过「谁有哪些卡」的名单。

没有名单,就没法作废。

那「改密码」为什么也救不了? 改密码相当于公司换了大门锁的密码,但门禁卡上只印了「张三」这个名字,没有「密码版本」这个信息,保安刷卡时压根不看密码——所以旧卡照样能刷。

三、账号被盗场景:攻击者的请求和正常用户无法区分

T+0      张三登录,拿到 token(有效期到 T+7200)
T+300    攻击者通过钓鱼链接 / XSS 截获了这张 token
T+1800   张三发现异常,改了密码,并点了「退出所有设备」
T+1801   攻击者拿着那张旧 token 来请求接口

纯 JWT 下,服务端看到的是:

验签名 -> 通过 ✓
看过期 -> 距离过期还有 5399 秒,未过期 ✓
查撤销 -> (没这一步,也没地方查)
结果:放行

攻击者用旧 token 成功访问了张三的账号。

最糟糕的一点:服务端日志里,攻击者发的全是「合法请求」,和正常用户完全无法区分。

加了 Redis 黑名单之后

张三点「退出所有设备」时,服务端把 token 里的 jti 写进黑名单:

验签名 -> 通过 ✓
看过期 -> 未过期 ✓
查撤销 -> 在黑名单里 ✗
结果:已被撤销 ✓ 攻击者被挡住

根源对比:有效性到底放在谁手里

Session(有状态) JWT(无状态)
登录时 服务端在 Redis 存一份会话记录 服务端签个名,然后什么都不存
每次请求 拿 sessionId 去 Redis 查:还在吗? 只看 token 自己:签名真吗?过期没?
退出时 把 Redis 那条删掉 → 下次请求立刻失败 无处可删,服务端什么都没存
有效性归谁管 服务端,随时能改主意 token 自己,签发后收不回来

无状态的好处 = 服务端不用存、不用查 无状态的代价 = 服务端事后管不了

四、四种撤销方案的取舍

方案 退出生效延迟 每请求查存储 能踢人
纯 JWT(无状态) 永不生效 0 次 ❌
JWT + Redis 黑名单 立即 1 次 ✅
JWT + 用户版本号 立即 1 次(可本地缓存) ✅
短 token + Refresh 最长等 token 过期 0~1 次 ⚠️ 有延迟
Session + Redis 立即 1 次 ✅

关键洞察:

要让「退出」立即生效,就必须在服务端存点东西;只要存了,每个请求就得查它——无状态的优势被抵消了一部分。

三种方案怎么选

方案一:短 token + Refresh Token

把 access token 有效期压到 15 分钟,用 refresh token 续期。撤销窗口缩到 15 分钟以内。

代价:引入 refresh token 的完整复杂度(下一篇文章专门讲)。

方案二:Redis 黑名单

存已撤销的 jti,网关验签之后多查一步。

// 退出时:把 jti 写进黑名单,TTL 设为 token 剩余有效期
public void logout(String token) {
    Claims claims = parse(token);
    String jti = claims.getId();
    long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
    if (ttl > 0) {
        redis.setex("black_token:" + jti, ttl / 1000, "1");
    }
}

// 网关校验时:验签 -> 查黑名单
public boolean isValid(String token) {
    Claims claims = verifySignature(token);   // 验签名
    if (claims == null) return false;
    if (claims.getExpiration().before(new Date())) return false;  // 看过期
    return !redis.exists("black_token:" + claims.getId());        // 查撤销
}

注意 TTL 的设计:黑名单条目只需要活到 token 自然过期为止,过期后自动清理,不会无限膨胀。这是黑名单方案最关键的一个细节。

方案三:用户版本号

用户表加一个 token_version 字段,签发时写进 token,想全端下线就把版本号加一。

ALTER TABLE sys_user ADD COLUMN token_version INT DEFAULT 1;
// 签发时把版本号写进 token
String token = Jwts.builder()
    .setSubject(userId)
    .claim("ver", user.getTokenVersion())
    .signWith(key)
    .compact();

// 校验时比对
Integer tokenVer = claims.get("ver", Integer.class);
Integer currentVer = userCache.getTokenVersion(userId);
if (!currentVer.equals(tokenVer)) {
    throw new UnauthorizedException("登录已失效,请重新登录");
}

改密码时把版本号 +1,所有旧 token 立刻失效——这是黑名单方案做不到的(黑名单要枚举该用户所有未过期的 jti)。

一个常见疑问:加了黑名单,跟用 Session 有什么区别?

区别在两块: ① JWT 不用存全量会话,黑名单里只有已撤销的那一小部分,量级小得多; ② token 自带用户信息,网关验签就能拿到 user_id,下游服务透传身份也不用查存储。

所以优势还在,只是不再是「完全无状态」。

五、多端退出:jti 和版本号解决的不是同一件事

手机上点退出 → 用 jti 黑名单只能拉黑【手机那一个】
             电脑端的 token 完全不受影响,继续有效

想踢掉该用户的所有端 → 必须用【用户级版本号】
                       用户表存 token_version,想全踢就 +1
需求 用什么 原因
退出当前设备 jti 黑名单 精确到单个 token
退出所有设备 / 改密码后全端失效 用户版本号 影响该用户全部 token
管理员禁用账号 用户状态 + 版本号 状态判断 + 强制失效

要用用户版本号,网关每个请求都得查一次当前 version——又一次对无状态的让步。实践中可以用本地缓存 + 短过期(比如缓存 5 秒)把开销压下来,代价是踢人生效有几秒延迟。

六、概念澄清:统一鉴权 ≠ 单点登录

这两个词经常被混用,但它们属于完全不同的层次:

定义 例子 属于哪一层
统一鉴权 一个应用内部,多个微服务共用一套鉴权逻辑 同一个 App 内访问商品 / 订单 / 会员用同一个 token 微服务架构层面
单点登录 SSO 一次登录,访问多个相互独立的系统 登录淘宝可进天猫、闲鱼;企业登录一次 OA / CRM / 邮箱全通 跨系统层面,需要 CAS / OAuth2 / SAML

一个面向消费者的单个 App,即使服务拆成了微服务,对用户而言仍然只是一个 App——准确说法是「统一鉴权」,不是「单点登录」。

判断标准很简单:问一句「你说的单点登录,是跨哪几个系统?」 如果答不上来,那就是用词错了。

七、JWT 选型的本质

说法 问题
❌「JWT 是无状态的,所以性能好」 下一句必然是「那退出登录怎么办」,立刻被动
❌「JWT 很安全,签名没法伪造」 签名防的是篡改,防不了盗用——这是两个不同的威胁

签名能保证「token 内容没被改过」,但挡不住「token 被别人拿到」。这是理解 JWT 安全边界的关键。

该不该用 JWT

诚实的技术判断是这样的:

如果只有一个单体应用、又要求强撤销,Session 更简单。

选 JWT 主要是两个场景:微服务下 token 要跨服务传递,以及 App / 小程序 / H5 的多端场景。

但 JWT 不是免费的——它把「撤销能力」换成了「无状态」。选型前应该先问清业务对「即时下线」的要求有多强,再决定用哪套。

小结

  • JWT 的验证只做两件事:验签名、看过期时间,不查任何存储
  • 无状态的好处与代价是同一枚硬币的两面:不用存、不用查 ⇄ 事后管不了
  • 纯 JWT 下「退出登录」只有一个幽灵窗口——token 到期为止,改密码也救不了
  • 四种撤销方案:短 token+refresh、jti 黑名单、用户版本号、退回 Session
  • 黑名单 key 的 TTL 只需覆盖 token 剩余有效期,不会无限膨胀
  • 用户版本号解决全端下线,jti 黑名单只管单个 token,两者不能互相替代
  • 统一鉴权(应用内)和单点登录(跨系统)是两个不同层次的概念
  • 签名防篡改,不防盗用

JWT 是用「服务端事后管不了」换来了「服务端不用存、不用查」。 你选它,就是接受了这笔交易——能把这句话说出来,说明你真的理解了这个技术,而不只是会调 API。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。