一个自己挖的坑:JWT 载荷里必须带 jti
一句话结论
JWT 一旦引入黑名单「立即失效」,Token 的唯一性就成了生命线。iat/exp 精确到秒、没有随机性,所以必须自己补一个 jti。
现象
做完 Token 黑名单(登出即失效)之后,我测登出,一切正常。
直到有一次:登出之后立刻用同一个账号重新登录,被拒了,返回 40101 Token 已失效。
用户什么都没做错,却被自己的登出操作锁在了门外。
排查过程
| 怀疑对象 | 怎么排除的 |
|---|---|
| 前端没清 Token | 后端直接 curl 复现,问题一样 |
| 拦截器逻辑写错了 | 单测里手动构造一个「不在黑名单里的 Token」,能正常访问 |
| 黑名单记录写错了 | 登出后 Redis 里确实写入了,过期时间也对 |
| 签发的 Token 本身有问题 | 把两次登录的 Token 字符串打出来对比 —— 一模一样 |
最后一行是突破口。
根因
JWT 的标准载荷只有这些:
{
"sub": "1",
"iat": 1780000000,
"exp": 1780086400
}
iat(签发时间)和 exp(过期时间)都精确到秒,而且没有任何随机成分。
同一个账号在同一秒内签发两次,载荷逐字节相同 → 加上相同的 header 和相同的签名密钥 → 整个 Token 字符串完全相同。
于是:
12:00:00登录,拿到 Token A12:00:01登出,Token A 进了黑名单12:00:01立刻重新登录 → 载荷一样 → 又拿到了 Token A- 拦截器检查:Token A 在黑名单里 → 401
用户被自己的操作挡在门外。
修法:加 jti
private String buildToken(Map<String, Object> claims, Long ttl) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + ttl);
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
// jti:随机唯一标识。没有它,iat/exp 只精确到秒,
// 同一秒内两次登录会生成完全相同的 Token
.id(UUID.randomUUID().toString())
.claims(claims)
.issuedAt(now)
.expiration(expireDate)
.signWith(key)
.compact();
}
jti(JWT ID)是 JWT 标准里的字段,语义就是「本次签发的唯一标识」,带随机性。
配套:黑名单在 Redis 不可用时要降级
public boolean isBlacklisted(String token) {
try {
return Boolean.TRUE.equals(
stringRedisTemplate.hasKey(RedisKey.TOKEN_BLACKLIST + token));
} catch (Exception e) {
// Redis 不可用时跳过校验:降级放行
log.warn("Redis 不可用,跳过 Token 黑名单校验(降级放行): {}", e.getMessage());
return false;
}
}
这里有个有意的取舍:黑名单校验失败时选择放行而不是拒绝。
- 放行的风险:已登出的 Token 在 Redis 挂掉期间还能用一会儿
- 拒绝的风险:所有人立刻登不出去
我选了后者更不可接受的那个。这和「Redis 挂了跳过评论限流」是同一个判断标准:外部依赖是增强,不是必需。
但要注意:写入黑名单不能这样降级。登出时如果写不进去却告诉用户「已登出」,那就是在骗人,所以写入失败必须明确报错。
复盘
这个 bug 手工点很难复现——你得在同一秒内完成「登出 + 登录」两个动作。我是写全量回归脚本、批量跑用例的时候才撞上的。
由此得出一条我一直在用的原则:
任何带「唯一标识」语义的东西,都不该依赖时间戳的精度。
时间戳的精度取决于你的实现细节(毫秒还是秒、是字符串还是数值),而「唯一」这件事应该由你自己提供随机性来保证。
类似的例子还有:数据库主键不能靠「时间戳 + 偏移量」去凑、分布式锁的 value 不能只用时间戳、幂等键不能只用「用户 ID」。
小结
JWT 是无状态的,所以「让某个 Token 立即失效」这件事必须依赖外部状态(黑名单)。
而一旦引入了黑名单,Token 的唯一性就成了生命线——jti 从「可选字段」变成了「必需字段」。