JWT 的无状态困境:签发之后为什么收不回来
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 |
保安(网关)怎么查验? 只看两件事:
- 钢印是不是真的 → 验签名
- 时间过没过 → 看
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_* 四个变量后自动开启。