文章 559
评论 5
浏览 217355
JWT 无状态失效难题:密码修改后旧 Token 仍有效?Redis 黑名单+版本号校验

JWT 无状态失效难题:密码修改后旧 Token 仍有效?Redis 黑名单+版本号校验

公司的一个用户改了密码,但改了之后发现手机上的 App 还是登录状态,能正常操作。查了半天发现是 JWT 的问题——旧 Token 还没过期,拿着旧密码签发的 Token 照样能通过验证。用户以为改密码后旧设备上的登录会失效,实际上 JWT 根本不关心密码是否被改过。

这就是 JWT 无状态设计带来的副作用。服务端不存任何 Token 状态,所以也没法"让它失效"。今天聊聊怎么在保持 JWT 无状态优势的前提下,解决"主动失效"的需求。


问题:为什么旧 Token 还能用

JWT 的验证逻辑只看三样东西:

  • Token 的签名是否正确(证明它没被篡改)
  • Token 是否过期(exp 字段)
  • Token 的签发者是否正确(iss 字段)

它不看"这个用户的密码有没有被改过"。因为 JWT 的哲学是"服务端不记状态"——Token 自包含所有信息,验证时不需要查数据库。

这意味着一旦 Token 签发出去,在它自然过期之前,没有任何办法让它失效——除非你再往里加一个"失效机制"。


方案一:Redis 黑名单(最直观)

把需要失效的 Token 放进 Redis 黑名单里,验证时先查黑名单:

public boolean validateToken(String token) {
    // 第一步:查黑名单
    if (redis.exists("blacklist:" + token)) {
        throw new TokenInvalidException("Token 已被主动失效");
    }

    // 第二步:正常 JWT 验证
    Claims claims = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody();

    return true;
}

// 改密码时,把旧 Token 加入黑名单
public void onPasswordChanged(String userId, String oldToken) {
    long ttl = getTokenRemainingTime(oldToken);
    redis.set("blacklist:" + oldToken, "1", ttl);  // 跟 Token 本身同时过期
}

关键细节:黑名单的 Redis key 过期时间 = Token 本身的剩余有效时间。Token 自然过期后,黑名单里的记录也自动清除,不会一直积压。

但黑名单方案的代价是——每次验证都要查一次 Redis。如果系统压力比较大,可以把黑名单缓存在本地内存里,定期从 Redis 同步。


方案二:用户版本号(更轻量)

黑名单要存 Token 本体。如果公司有 100 万用户,极端情况下每个用户都有一个被拉黑的 Token,Redis 里就是 100 万个 key。

版本号的思路更轻量:每个用户维护一个 tokenVersion。JWT 里嵌入这个版本号。改密码时把版本号 +1,版本号不匹配的 Token 全部失效。

// 签发 Token 时,把版本号写进 JWT payload
public String createToken(String userId) {
    long version = getUserTokenVersion(userId);
    return Jwts.builder()
            .setSubject(userId)
            .claim("ver", version)  // ★ 嵌入版本号
            .setExpiration(new Date(System.currentTimeMillis() + 7200000))
            .signWith(secretKey)
            .compact();
}

// 验证时比对版本号
public boolean validateToken(String token) {
    Claims claims = parseToken(token);
    long tokenVersion = claims.get("ver", Long.class);
    long currentVersion = getUserTokenVersion(claims.getSubject());

    if (tokenVersion < currentVersion) {
        throw new TokenInvalidException("Token 已失效(版本号过期)");
    }
    return true;
}

// 改密码 → 版本号 +1 → 旧 Token 全部失效
public void onPasswordChanged(String userId) {
    redis.incr("user:version:" + userId);
}

版本号存在 Redis 里,每次验证都查一次。但查的是一个数字(user:version:userId),比黑名单查 Token 本体轻得多。

而且版本号天然支持"一把全部失效"——改一次密码,所有旧 Token 全挂。不像黑名单需要手动把每一个 Token 加进去。


方案三:黑名单 + 版本号组合

两个方案各有优势,可以组合使用:

版本号(全局失效)→ 改密码 + 登出全部设备
黑名单(精确失效)→ 封禁某个特定的 Token(比如管理员发现可疑操作)

版本号管大面,黑名单管个案。验证时先查版本号(一次 Redis GET),再查黑名单(如果需要)。大多数情况下版本号就够了。


一个注意点:不要太频繁查 Redis

如果接入层做了版本号校验,每次请求都查一次 Redis,高峰期 Redis QPS 会很高。优化的方式:

网关层做版本号校验,把版本号缓存 60 秒。改密码到 Token 失效最多延迟 60 秒——这个延迟对安全影响不大,但对 Redis 的 QPS 影响很大。

@Cacheable(value = "userVersion", key = "#userId")
public long getUserTokenVersion(String userId) {
    String val = redis.get("user:version:" + userId);
    return val == null ? 1 : Long.parseLong(val);
}

60 秒的本地缓存让 99% 的请求不需要查 Redis。只有改密码后的第一个请求会穿透缓存读到新版本号。


总结

JWT 的无状态是双刃剑——部署简单,但没法主动让一个 Token 失效。

两招补上这个短板:

  • 版本号(全局失效)—— JWT 里嵌入 ver,改密码时 INCR。一次操作,所有旧 Token 全挂
  • 黑名单(精确失效)—— 特定 Token 加入 Redis 黑名单,TTL = Token 剩余时间,自动清理

组合使用:版本号管全局、黑名单管单个。网关层加 60 秒缓存,Redis 压力忽略不计。



标题:JWT 无状态失效难题:密码修改后旧 Token 仍有效?Redis 黑名单+版本号校验
作者:jiangyi
地址:http://jiangyi.space/articles/2026/07/05/1783148415759.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消