JWT双令牌认证:无感刷新与黑名单机制实战解析
这类认证方案最值得关注的不是功能列表而是能不能在普通服务器环境里稳定跑起来同时兼顾安全性和用户体验。大厂之所以标配双令牌 无感刷新 黑名单核心是为了解决单令牌方案里“要么频繁登录要么长期风险”的两难问题。我一般会先拆清楚三个关键动作短令牌用来日常接口请求长令牌用来在后台静默换新短令牌黑名单确保每次换新后旧令牌立刻失效。这个流程里最容易出问题的是刷新时机判断、黑名单存储选型和令牌状态同步。下面按实际落地顺序拆一遍。1. 先搞清楚双令牌到底解决了什么实际问题如果你只用单个 JWT会面临两个典型困境如果把过期时间设得很短比如 15 分钟用户每隔一会儿就要重新登录体验极差。如果把过期时间设得很长比如 7 天一旦令牌泄露攻击者就能长期冒用身份。双令牌方案把这两个问题拆开处理access_token短效负责日常接口认证过期时间通常 15~30 分钟。过期后不是让用户登录而是用 refresh_token 静默换新。refresh_token长效只用于刷新 access_token本身不能直接访问业务接口过期时间 7~30 天。每次刷新后旧的 refresh_token 会进入黑名单确保即使泄露也仅能使用一次。这样既减少了频繁登录又控制了单次泄露的影响范围。1.1 无感刷新的关键在拦截器和状态判断无感刷新不是前端定时轮询而是在接口请求时统一处理前端发起请求时携带 access_token。如果接口返回“令牌过期”前端不是直接跳登录页而是先尝试用 refresh_token 调用刷新接口。刷新成功则用新 access_token 重试原请求用户无感知刷新失败如 refresh_token 也过期才跳转登录。这个流程里最容易漏掉的是并发请求时的刷新冲突当第一个请求触发刷新时其他并发请求不能各自重复刷新需要排队等待新令牌。1.2 黑名单的核心作用是防止令牌回退没有黑名单时刷新令牌后旧 access_token 在有效期内仍可使用。攻击者如果截获旧令牌就能在有效期内一直冒用。黑名单机制在每次刷新时将旧令牌标识如 jti 或令牌签名存入 Redis 并设置过期时间略长于原令牌有效期。每次认证时除了验证签名和过期时间还要查一下是否在黑名单中。这里的关键是存储选型用 Redis 是因为它有过期自动清理能力适合存短期黑名单数据。如果业务量很小也可以用内存缓存但要自己处理重启数据丢失问题。2. 准备一个可跑通的本地演示环境演示环境需要这些基础组件一个能签发和验证 JWT 的后端服务这里用 Spring Boot 示例Redis 服务用于存黑名单前端页面模拟令牌使用和刷新流程2.1 先快速搭起 Redis 服务如果你用 Docker一行命令就能启动docker run -d --name redis-jwt -p 6379:6379 redis:7-alpine如果本地装 Redis下载安装包后修改配置# 允许外部连接设置密码生产环境必须 bind 0.0.0.0 requirepass your_redis_password启动后用 redis-cli 测试连通性redis-cli -h 127.0.0.1 -p 6379 -a your_redis_password ping PONG2.2 Spring Boot 项目引入关键依赖Maven 配置dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency配置文件 application.ymlspring: redis: host: localhost port: 6379 password: your_redis_password timeout: 2000ms jwt: secret: your-jwt-secret-key-at-least-32-chars access-token-expire: 900000 # 15分钟 refresh-token-expire: 604800000 # 7天注意JWT 密钥长度至少 32 字符不要用简单单词。生产环境要从配置中心或环境变量读取不能硬编码。2.3 封装 JWT 工具类处理签发和验证工具类要处理三件事签发双令牌、验证令牌有效性、检查黑名单。Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.access-token-expire}) private long accessTokenExpire; Value(${jwt.refresh-token-expire}) private long refreshTokenExpire; Autowired private RedisTemplateString, Object redisTemplate; // 生成访问令牌 public String generateAccessToken(String userId) { return Jwts.builder() .setSubject(userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() accessTokenExpire)) .setId(UUID.randomUUID().toString()) // jti 用于黑名单 .signWith(SignatureAlgorithm.HS256, secret) .compact(); } // 生成刷新令牌 public String generateRefreshToken(String userId) { return Jwts.builder() .setSubject(userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() refreshTokenExpire)) .setId(UUID.randomUUID().toString()) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } // 验证令牌并返回用户ID public String validateToken(String token) { try { Claims claims Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); // 检查黑名单 if (isInBlacklist(token)) { throw new ExpiredJwtException(null, claims, Token revoked); } return claims.getSubject(); } catch (ExpiredJwtException e) { // 过期异常单独处理用于触发刷新 throw e; } catch (Exception e) { throw new SecurityException(Invalid token); } } // 检查令牌是否在黑名单 private boolean isInBlacklist(String token) { String jti getJtiFromToken(token); return redisTemplate.hasKey(jwt:blacklist: jti); } // 将令牌加入黑名单 public void addToBlacklist(String token) { String jti getJtiFromToken(token); long expireTime getExpireTimeFromToken(token) - System.currentTimeMillis(); if (expireTime 0) { redisTemplate.opsForValue().set( jwt:blacklist: jti, revoked, expireTime, TimeUnit.MILLISECONDS ); } } }这个工具类提供了基础能力接下来要在控制器里组织登录和刷新流程。3. 实现登录、刷新和黑名单联动的核心流程流程设计的重点是状态清晰和异常处理。我一般会先实现最小可行流程再补充边界情况。3.1 登录接口同时返回双令牌登录成功后生成一对令牌返回给前端RestController RequestMapping(/auth) public class AuthController { Autowired private JwtUtil jwtUtil; PostMapping(/login) public ResponseEntityMapString, String login(RequestBody LoginRequest request) { // 1. 验证用户名密码这里简化为直接验证 if (!admin.equals(request.getUsername()) || !123456.equals(request.getPassword())) { return ResponseEntity.status(401).build(); } // 2. 生成双令牌 String userId user123; String accessToken jwtUtil.generateAccessToken(userId); String refreshToken jwtUtil.generateRefreshToken(userId); // 3. 将 refresh_token 关联用户存入 Redis用于单设备登录控制 redisTemplate.opsForValue().set( jwt:refresh: userId, refreshToken, jwtUtil.getRefreshTokenExpire(), TimeUnit.MILLISECONDS ); // 4. 返回令牌 MapString, String result new HashMap(); result.put(access_token, accessToken); result.put(refresh_token, refreshToken); result.put(expires_in, String.valueOf(jwtUtil.getAccessTokenExpire() / 1000)); return ResponseEntity.ok(result); } }前端收到后将 access_token 用于接口请求refresh_token 安全存储如 httpOnly cookie 或安全存储区。3.2 刷新接口处理令牌轮换刷新接口是无感刷新的核心要做到原子性操作PostMapping(/refresh) public ResponseEntityMapString, String refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); try { // 1. 验证 refresh_token 有效性 String userId jwtUtil.validateToken(refreshToken); // 2. 检查是否与服务器存储一致防单设备登录 String storedRefreshToken (String) redisTemplate.opsForValue().get(jwt:refresh: userId); if (!refreshToken.equals(storedRefreshToken)) { return ResponseEntity.status(401).build(); } // 3. 作废旧 access_token如果有的话 String oldAccessToken request.getOldAccessToken(); if (StringUtils.hasText(oldAccessToken)) { jwtUtil.addToBlacklist(oldAccessToken); } // 4. 生成新令牌 String newAccessToken jwtUtil.generateAccessToken(userId); String newRefreshToken jwtUtil.generateRefreshToken(userId); // 5. 更新服务器端的 refresh_token redisTemplate.opsForValue().set( jwt:refresh: userId, newRefreshToken, jwtUtil.getRefreshTokenExpire(), TimeUnit.MILLISECONDS ); // 6. 将旧 refresh_token 加入黑名单 jwtUtil.addToBlacklist(refreshToken); MapString, String result new HashMap(); result.put(access_token, newAccessToken); result.put(refresh_token, newRefreshToken); result.put(expires_in, String.valueOf(jwtUtil.getAccessTokenExpire() / 1000)); return ResponseEntity.ok(result); } catch (Exception e) { return ResponseEntity.status(401).build(); } }这个流程确保了每次刷新后旧令牌立即失效新令牌立即生效。3.3 拦截器统一处理认证和刷新触发拦截器负责检查 access_token 有效性并在过期时触发刷新机制Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 排除登录和刷新接口 if (request.getRequestURI().contains(/auth/login) || request.getRequestURI().contains(/auth/refresh)) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !token.startsWith(Bearer )) { sendErrorResponse(response, 401, Missing token); return false; } token token.substring(7); try { String userId jwtUtil.validateToken(token); request.setAttribute(userId, userId); return true; } catch (ExpiredJwtException e) { // 令牌过期返回特定状态码让前端触发刷新 sendErrorResponse(response, 419, Token expired); // 419 自定义状态码表示需要刷新 return false; } catch (Exception e) { sendErrorResponse(response, 401, Invalid token); return false; } } private void sendErrorResponse(HttpServletResponse response, int status, String message) { try { response.setStatus(status); response.setContentType(application/json); response.getWriter().write({\code\: status , \message\: \ message \}); } catch (IOException e) { e.printStackTrace(); } } }前端收到 419 状态码时就知道要调用刷新接口了。4. 前端处理无感刷新的关键逻辑前端实现的重点是错误拦截和请求重试。我用 axios 拦截器示例class AuthService { constructor() { this.accessToken localStorage.getItem(access_token); this.refreshToken localStorage.getItem(refresh_token); this.isRefreshing false; this.requestsQueue []; this.setupInterceptors(); } setupInterceptors() { // 请求拦截器自动添加 token axios.interceptors.request.use(config { if (this.accessToken !config.url.includes(/auth/)) { config.headers.Authorization Bearer ${this.accessToken}; } return config; }); // 响应拦截器处理 token 过期 axios.interceptors.response.use( response response, async error { if (error.response?.status 419) { // token 过期尝试刷新 if (!this.isRefreshing) { this.isRefreshing true; try { const newTokens await this.refreshTokens(); this.accessToken newTokens.access_token; this.refreshToken newTokens.refresh_token; // 重试队列中的请求 this.requestsQueue.forEach(callback callback(this.accessToken)); this.requestsQueue []; // 重试当前请求 error.config.headers.Authorization Bearer ${this.accessToken}; return axios.request(error.config); } catch (refreshError) { // 刷新失败跳转登录 this.clearTokens(); window.location.href /login; return Promise.reject(refreshError); } finally { this.isRefreshing false; } } else { // 正在刷新中将请求加入队列 return new Promise(resolve { this.requestsQueue.push(token { error.config.headers.Authorization Bearer ${token}; resolve(axios.request(error.config)); }); }); } } return Promise.reject(error); } ); } async refreshTokens() { const response await axios.post(/auth/refresh, { refresh_token: this.refreshToken, old_access_token: this.accessToken }); localStorage.setItem(access_token, response.data.access_token); localStorage.setItem(refresh_token, response.data.refresh_token); return response.data; } clearTokens() { localStorage.removeItem(access_token); localStorage.removeItem(refresh_token); this.accessToken null; this.refreshToken null; } } // 初始化 const authService new AuthService();这个实现解决了并发请求时的重复刷新问题确保同一时间只有一个刷新请求。5. 生产环境必须处理的边界情况和性能优化单机演示能跑通只是第一步真正部署时要考虑分布式环境和异常场景。5.1 Redis 集群下的黑名单同步单机 Redis 没问题但集群环境下要考虑数据分片。黑名单查询需要保证一致性有几种方案方案一用 Redis 集群但确保同一个 jti 的黑名单记录始终落在同一节点。可以通过在 key 前加{jti}强制哈希到同一槽位。方案二用 Redis 主从复制所有写入走主节点读取可以从节点分担压力。方案三如果对实时性要求不高可以用本地缓存 Redis 广播失效消息。我一般先按方案一实现代码改动最小// 强制 key 落在同一槽位 String redisKey jwt:blacklist:{ jti }; redisTemplate.opsForValue().set(redisKey, revoked, expireTime, TimeUnit.MILLISECONDS);5.2 黑名单存储的优化策略随着用户量增长黑名单数据会持续积累。虽然 Redis 有过期自动清理但还是要监控内存使用。优化方向缩短黑名单过期时间比原令牌过期时间多 5-10 分钟即可不需要存满 refresh_token 的 7 天。定期清理脚本用 Redis 的 SCAN 命令定期查找已过期的黑名单记录虽然 Redis 会自动清理但内存回收可能有延迟。使用布隆过滤器如果黑名单量极大可以先布隆过滤器初步判断减少 Redis 查询压力。5.3 安全加固的额外措施基础方案能跑通后还要考虑这些安全加固点防止重放攻击在 JWT payload 中加入随机数nonce或请求标识服务端记录已使用的 nonce防止同一令牌重复使用令牌绑定将令牌与客户端指纹User-Agent、IP 等绑定异常使用时要求重新认证刷新令牌保护refresh_token 必须通过安全通道传输HTTPS存储时用 httpOnly Cookie 减少 XSS 风险设置适当的 SameSite 属性防 CSRF6. 常见问题排查顺序和调试技巧实际落地时最容易卡住的是环境配置和流程调试。我一般按这个顺序排查6.1 令牌生成和验证问题现象登录成功但接口认证失败。排查顺序检查 JWT 密钥是否一致服务重启后密钥变化会导致验证失败检查令牌过期时间设置单位是毫秒还是秒验证令牌签名算法HS256 需要足够长度的密钥检查令牌传输是否完整特别是前端截取 Bearer 前缀的逻辑调试技巧用 jwt.io 调试器解析令牌确认 payload 内容是否正确。6.2 Redis 连接和黑名单问题现象刷新后旧令牌仍能使用。排查顺序检查 Redis 连接是否正常telnet 端口测试确认黑名单 key 的过期时间设置是否正确验证黑名单查询逻辑是否执行加日志打印检查 Redis 集群环境下 key 是否分布到不同节点调试技巧直接连 Redis 查看黑名单 key 是否存在redis-cli -a your_password keys jwt:blacklist:* ttl jwt:blacklist:某个jti6.3 前端刷新流程问题现象页面卡在无限刷新循环。排查顺序检查刷新接口是否返回新令牌网络面板查看响应确认前端是否正确更新 localStorage 中的令牌验证请求队列逻辑是否正常并发请求处理检查刷新失败后的跳转逻辑是否执行调试技巧在刷新接口加日志确认每次刷新时旧令牌是否加入黑名单。6.4 性能问题排查现象认证接口响应变慢。排查顺序检查 Redis 查询耗时慢查询日志验证 JWT 验证逻辑是否有性能瓶颈检查黑名单数据量是否过大确认网络延迟影响监控指标关注接口平均响应时间、Redis 内存使用量、黑名单 key 数量增长趋势。我个人更建议先把单机版本跑稳定再考虑分布式扩展。这个方案真正落地时最该盯住的不是功能列表而是令牌状态一致性、黑名单存储可靠性和前端刷新流程的健壮性。如果只是学习验证本地 Redis 单服务节点完全够用如果要上生产环境就要把监控、日志和故障转移提前准备好。踩过几次之后我发现很多认证问题不是方案设计问题而是环境配置和异常处理没有覆盖全面。