阿飞的博客

一个自己挖的坑: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 字符串完全相同。

于是:

  1. 12:00:00 登录,拿到 Token A
  2. 12:00:01 登出,Token A 进了黑名单
  3. 12:00:01 立刻重新登录 → 载荷一样 → 又拿到了 Token A
  4. 拦截器检查: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 从「可选字段」变成了「必需字段」。

标签 Java踩坑记录

评论

0
加载中…
无需登录 · 同一 IP 每分钟限 3 条