news 2026/7/1 5:07:56

无状态的“皇帝新装”:JWT 注销、续签与黑名单的终极拷问

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
无状态的“皇帝新装”:JWT 注销、续签与黑名单的终极拷问

写在前面

你好,我是 Evan。

“JWT 不是无状态的吗?那用户退出登录后,Token 为什么还能用?”

这是我在一次 Code Review 中被问住的问题。我当时自信满满地设计了一套 JWT 认证方案——Access Token 有效期 30 分钟,Refresh Token 有效期 7 天。登录、鉴权一切正常,直到产品经理提出一个看似简单需求:“用户退出登录后,Token 要立即失效。”我愣住了。JWT 一旦签发,在过期之前它就是“活”的。服务器端根本没有存储它的状态,谈何“失效”?删除客户端的 Token?那只是掩耳盗铃——被删掉的 Token 照样能通过服务端认证。注销、改密码、踢人下线……这些再正常不过的业务需求,在无状态的 JWT 面前,变成了一道无解的题。

后来我才明白:JWT 的无状态是优势,也是枷锁。想要它“有状态”的能力,就必须付出“有状态”的代价。今天这篇文章,我想用一次完整的生产级实践,聊聊 JWT 的“不可能三角”——如何在不破坏无状态架构的前提下,实现可注销、可续签、可管控的令牌体系。

一、JWT 的“皇帝新装”:无状态的光环与阴影

1.1 为什么 JWT 如此受欢迎?

JWT(JSON Web Token)之所以成为分布式系统的认证标配,核心在于它的无状态性

  • 服务端不需要存储 Session,水平扩展零障碍

  • Token 自包含用户信息和签名,一次验证即可信任

  • 天然适合微服务、跨域、移动端等场景

但无状态的另一面是:一旦签发,在 exp 时间到达之前,这个 Token 就是“不死之身”

1.2 无状态的“三宗罪”

一句话总结:JWT 的“无状态”让认证变得简单,却让注销变得困难。你无法“撤销”一个已经发出去的 Token,只能等它自己过期。

这就引出了我们今天要解决的核心问题:如何在保留 JWT 无状态优势的同时,获得“有状态”的控制能力?

二、破局之道:JWT + Redis 的“有状态无状态”混合架构

2.1 核心思路:用 Redis 给 JWT 加一个“开关”

JWT 本身无状态,但我们可以借助外部存储(Redis)来记录 Token 的“生死状态”。这样既保留了 JWT 的自包含和分布式优势,又获得了主动撤销的能力。

关键设计原则:

  • 最小化存储:Redis 只存必要的状态标记,不存完整的用户数据

  • TTL 对齐:Redis 键的过期时间严格对齐 JWT 的exp时间,避免永久堆积

  • 异步清理:黑名单过期后自动删除,无需人工维护

2.2 白名单 vs 黑名单:选哪个?

黑名单的优势在于:注销是低频操作,而正常请求是高频操作。用黑名单,99% 的请求不需要查 Redis(或者只需要查一次黑名单),而白名单每次都要查。在生产环境中,黑名单是绝对的主流方案

三、黑名单实战:从理论到代码

3.1 JWT 中的 jti:为每个 Token 贴上“身份证”

要实现黑名单,首先要让每个 Token 可被唯一标识。JWT 标准中定义了jti(JWT ID)字段,专门用于此目的。

// 生成 JWT 时注入唯一 jti String jwt = Jwts.builder() .setId(UUID.randomUUID().toString()) // 关键:唯一令牌ID .setSubject("user123") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30分钟 .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

3.2 注销接口:将 jti 加入黑名单

@PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) { String token = authHeader.replace("Bearer ", ""); Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); Date exp = claims.getExpiration(); long ttl = exp.getTime() - System.currentTimeMillis(); // 将 jti 存入 Redis 黑名单,TTL = Token 剩余有效期 + 缓冲时间 // 使用 SET 命令,key 为 blacklist:{jti},过期时间对齐 Token 剩余时间[reference:17] if (ttl > 0) { redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", ttl + 30_000, // 多留 30 秒缓冲,避免时钟偏差 TimeUnit.MILLISECONDS ); } return ResponseEntity.ok("注销成功"); }

3.3 鉴权过滤器:每次请求检查黑名单

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String, String> redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String token = extractToken(request); if (token == null) { chain.doFilter(request, response); return; } // 1. 解析 JWT,获取 jti Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); // 2. 检查黑名单[reference:18] Boolean isBlacklisted = redisTemplate.hasKey("blacklist:" + jti); if (Boolean.TRUE.equals(isBlacklisted)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } // 3. 放行 chain.doFilter(request, response); } }

3.4 黑名单的“生命周期管理”

黑名单最怕的就是无限膨胀。想象一下,如果每个注销的 Token 都永久留在 Redis 里,几百万用户注销后,Redis 就爆了。

解决方案:Redis 键的 TTL 严格对齐 JWT 的剩余有效期。Token 过期后,黑名单中的记录也自动消失。

// TTL 计算:Token 剩余有效期 + 30 秒缓冲 long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", Math.max(ttl, 0) + 30_000, // 至少 30 秒 TimeUnit.MILLISECONDS );

四、续签的艺术:Access Token + Refresh Token 双令牌机制

黑名单解决了“注销”问题,但还有一个更常见的场景:Token 过期了怎么办?总不能让用户每隔 30 分钟就重新登录一次吧。

4.1 双令牌架构

4.2 核心设计要点

4.3 Refresh Token 轮转实现

@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // 1. 检查 Refresh Token 是否在黑名单中 String jti = extractJti(refreshToken); if (redisTemplate.hasKey("blacklist:refresh:" + jti)) { return ResponseEntity.status(401).body("Refresh Token 已失效"); } // 2. 校验 Refresh Token 签名和有效期 Claims claims = validateToken(refreshToken); // 3. 将旧的 Refresh Token 加入黑名单[reference:28] redisTemplate.opsForValue().set( "blacklist:refresh:" + jti, "revoked", getRemainingTTL(claims) + 30_000, TimeUnit.MILLISECONDS ); // 4. 生成新的 Access Token + Refresh Token[reference:29] String newAccessToken = generateAccessToken(claims.getSubject()); String newRefreshToken = generateRefreshToken(claims.getSubject()); return ResponseEntity.ok(new TokenPair(newAccessToken, newRefreshToken)); }

为什么要把旧的 Refresh Token 加入黑名单?如果不这样做,一个 Refresh Token 可以被无限次使用来换取新的 Access Token——相当于 Refresh Token 永不失效。

五、进阶方案:用户级版本号——一票否决所有 Token

黑名单方案的问题在于:每个 Token 需要单独存储。如果用户在多设备登录,注销时需要把每个设备的 Token 都加入黑名单,操作繁琐。

更好的方案:用户级版本号(user_version)

核心逻辑

  • 每个用户在 Redis 中维护一个version计数器

  • 签发 Token 时,将当前version写入 Token 的 Claims

  • 每次请求验证时,对比 Token 中的version和 Redis 中的最新version

  • 不一致则拒绝

优点:一次操作(version++)即可让该用户所有Token 失效,无需遍历黑名单。每个用户只占用一个 Redis Key,内存开销极小。

适用场景:修改密码、账号封禁、强制所有设备下线。

六、完整架构总览

七、常见陷阱与最佳实践

陷阱 1:黑名单无过期策略

错误做法:将注销的 Token 永久存入 Redis。
正确做法:TTL 严格对齐 Token 剩余有效期。

陷阱 2:Refresh Token 不轮转

错误做法:每次刷新只换 Access Token,Refresh Token 不变。
正确做法:每次刷新生成新的 Refresh Token,旧 Token 加入黑名单。

陷阱 3:黑名单查询影响性能

错误做法:每次请求都查两次 Redis(黑名单 + user_version)。
正确做法:将黑名单查询结果缓存到 ThreadLocal 或本地缓存(Caffeine),减少 Redis 压力。

陷阱 4:把敏感信息放入 Payload

错误做法:在 JWT Payload 中存放密码、身份证号等敏感信息。
正确做法:Payload 只存放非敏感的用户标识(如 userId、role),敏感信息走数据库查询。

陷阱 5:密钥硬编码

错误做法secretKey = "mySecret"写在代码里。
正确做法:使用 KMS / Vault 托管密钥,支持密钥轮换。

八、总结:JWT 不是银弹,但用对组合就是神器

回到开头的问题:JWT 如何实现注销、续签和黑名单?

答案不是“不用 JWT”,而是“JWT + Redis”的混合架构

  • 黑名单(按 jti):解决单 Token 注销问题

  • Refresh Token 轮转:解决安全续签问题

  • 用户级版本号(user_version):解决批量失效问题

这套方案既保留了 JWT 的无状态优势(水平扩展、跨域、自包含),又通过 Redis 获得了“有状态”的控制能力(注销、踢人、改密码)。

最后送你一张决策表

需求推荐方案实现成本
用户退出登录jti 黑名单
修改密码/封号user_version 版本号
Token 过期续签Access + Refresh 双 Token + 轮转
多设备登录控制Redis 存储设备列表
强制所有设备下线user_version + 1

JWT 不是银弹,但用对组合,它就是神器。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/1 5:05:25

CAD快捷键

F7 关闭开启网格Ctrl9 开启命令行OP命令打开选项双击鼠标中键&#xff0c;显示全部图纸按住中键拖动图纸空格和回车代表确认结束F8开启图标F3快速捕捉画螺丝&#xff08;画3d&#xff09;F 倒角先后选上下两边线IN交集Ds 草图设置Mi 镜像Tr 修剪Co 选择物体上的点复制M 选择物…

作者头像 李华
网站建设 2026/7/1 5:02:54

无人直升机 6 自由度动力学建模与 Matlab/Simulink 仿真阶段性实践总结

一、前言 近期完成公司自研单旋翼无人直升机动力学仿真项目阶段性开发&#xff0c;核心目标是搭建完整 6-DOF 刚体动力学模型、完成配平仿真与基础飞控验证。目前项目暂时暂停开发&#xff0c;已完成文献调研、主 / 尾旋翼 Simulink 模型搭建、基于 JSBSim 开源 UH-1H 数据集的…

作者头像 李华
网站建设 2026/7/1 4:58:59

SAP物料分类账ML配置避坑指南:从OBYC科目到CKMSTART启动的完整流程

SAP物料分类账ML配置实战&#xff1a;从OBYC科目到CKMSTART的避坑手册物料分类账&#xff08;Material Ledger&#xff0c;简称ML&#xff09;作为SAP系统中成本核算的核心模块&#xff0c;其配置复杂度常常让FICO顾问望而生畏。在实际项目中&#xff0c;一个错误的OBYC科目配置…

作者头像 李华