公司的一个用户改了密码,但改了之后发现手机上的 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 压力忽略不计。
