在传统单体的登录方案里,服务端通常靠 Session 保存用户状态,但在前后端分离、分布式部署的场景下,无状态的 JWT 成了主流选择。不过 JWT 一旦签发就无法主动作废,这就带来了两个绕不开的问题:Token 有效期设太短,用户频繁掉线;设太长,泄露后的风险窗口又太大。本文围绕这两个痛点,在 Spring Boot 项目里实现一套完整的双 Token 刷新方案,并配合 Redis 黑名单机制,让过期的 Token 能安全续期、注销的 Token 能立即失效。

一、为什么要用双 Token 机制
单 Token 方案最大的矛盾在于有效期和安全性的取舍。假设把有效期设为 7 天,一旦这个 Token 被抓包或泄露,攻击者在整整一周内都可以冒充用户;如果设为 30 分钟,用户每半小时就要重新输一次密码,体验极差。
双 Token 机制的思路是把两种职责拆开:AccessToken 负责日常接口访问,有效期很短(比如 30 分钟);RefreshToken 负责续期,有效期较长(比如 7 天),且只在刷新接口中使用。这样即使 AccessToken 泄露,损失窗口也只有几十分钟,而用户侧因为有 RefreshToken 兜底,全程无感知。
需要注意,JWT 本身是无状态的,服务端签发后无法撤销。所以黑名单机制必须借助外部存储,业界最常见的做法是引入 Redis,把需要作废的 Token 的唯一标识(jti)写入 Redis 并设置与 Token 剩余有效期一致的过期时间,既保证安全性,又不会让 Redis 无限膨胀。
二、工程搭建与依赖配置
先准备一个基础的 Spring Boot 工程,引入 JWT 解析库 jjwt 和 Redis 依赖。jjwt 是 Java 生态里最常用的 JWT 库,API 清晰且持续维护。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<lt;dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifact>
</dependency>接着在配置文件中写好密钥和两种 Token 的有效期。密钥建议至少 256 位,生产环境中不要明文写在配置文件里,可以通过环境变量注入。
jwt: secret: my-secret-key-for-jwt-signing-please-change-in-production access-token-expire: 1800 # AccessToken 有效期,单位秒 refresh-token-expire: 604800 # RefreshToken 有效期,单位秒
这些配置通过一个配置属性类读取,方便在工具类中直接注入使用。
三、JWT 工具类与 Token 签发
核心工具类负责签发和解析 Token。签发时为每个 Token 生成一个唯一标识 jti,这个标识后续会用于黑名单判断。AccessToken 中可以放入用户 ID、角色等业务信息,RefreshToken 则尽量精简,只放用户 ID 和 Token 类型,避免多余信息暴露。
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expire}")
private long accessExpire;
@Value("${jwt.refresh-token-expire}")
private long refreshExpire;
private SecretKey key;
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String createAccessToken(Long userId) {
return Jwts.builder()
.setId(UUID.randomUUID().toString()) // jti,黑名单的关键
.setSubject(String.valueOf(userId))
.claim("type", "access")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessExpire * 1000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String createRefreshToken(Long userId) {
return Jwts.builder()
.setId(UUID.randomUUID().toString())
.setSubject(String.valueOf(userId))
.claim("type", "refresh")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshExpire * 1000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}解析方法中如果签名不合法或者 Token 已过期,jjwt 会直接抛出异常,我们在拦截器里统一捕获并区分处理。签名错误说明 Token 被篡改,直接返回 401;过期异常则返回一个约定的状态码,前端收到后携带 RefreshToken 调用刷新接口换新 Token。
四、Redis 黑名单与刷新令牌管理
黑名单的存储结构很简单:以 jwt:blacklist:{jti} 作为 key,value 随意,过期时间设为该 Token 的剩余有效期。这样即使 Redis 挂了部分数据,超过原有效期后黑名单记录自然失效,不会占用内存。
@Service
public class TokenBlacklistService {
@Autowired
private StringRedisTemplate redisTemplate;
// 将 Token 加入黑名单,expireSeconds 为该 Token 的剩余存活时间
public void addToBlacklist(String jti, long expireSeconds) {
if (expireSeconds > 0) {
redisTemplate.opsForValue().set(
"jwt:blacklist:" + jti, "1", expireSeconds, TimeUnit.SECONDS);
}
}
public boolean isBlacklisted(String jti) {
return Boolean.TRUE.equals(
redisTemplate.hasKey("jwt:blacklist:" + jti));
}
}对于 RefreshToken,除了黑名单外还要处理「一个用户多个 RefreshToken 并存」的问题。推荐做法是登录时把 RefreshToken 以 jwt:refresh:{userId} 为 key 存入 Redis,刷新时校验请求中的 RefreshToken 是否与 Redis 中的一致。这样实现单点登录式的刷新控制:新登录会顶掉旧会话,旧的 RefreshToken 即使没过期也无法再刷新,这是很多方案忽略掉的安全细节。
@Service
public class RefreshTokenService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private JwtUtil jwtUtil;
public void saveRefreshToken(Long userId, String refreshToken) {
long expire = jwtUtil.parse(refreshToken)
.getExpiration().getTime() / 1000 - System.currentTimeMillis() / 1000;
redisTemplate.opsForValue().set(
"jwt:refresh:" + userId, refreshToken, expire, TimeUnit.SECONDS);
}
public boolean validate(Long userId, String refreshToken) {
String saved = redisTemplate.opsForValue()
.get("jwt:refresh:" + userId);
return refreshToken.equals(saved);
}
}五、刷新接口与拦截器实现
刷新接口接收 RefreshToken,完整校验链路是:先验签,再确认类型是 refresh,然后查黑名单,最后与 Redis 中保存的值比对。全部通过后签发新的双 Token,同时把旧 RefreshToken 拉黑、保存新的 RefreshToken,形成一次性的刷新令牌,防止旧 Token 被重复利用。
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private TokenBlacklistService blacklistService;
@Autowired
private RefreshTokenService refreshTokenService;
@PostMapping("/refresh")
public Map<String, Object> refresh(@RequestBody Map<String, String> body) {
String refreshToken = body.get("refreshToken");
Claims claims;
try {
claims = jwtUtil.parse(refreshToken);
} catch (ExpiredJwtException e) {
throw new RuntimeException("刷新令牌已过期,请重新登录");
} catch (JwtException e) {
throw new RuntimeException("非法令牌");
}
if (!"refresh".equals(claims.get("type", String.class))) {
throw new RuntimeException("令牌类型错误");
}
Long userId = Long.valueOf(claims.getSubject());
if (blacklistService.isBlacklisted(claims.getId())
|| !refreshTokenService.validate(userId, refreshToken)) {
throw new RuntimeException("刷新令牌已失效");
}
// 签发新 Token,旧的拉黑(一次性使用)
long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
blacklistService.addToBlacklist(claims.getId(), remain / 1000);
String newAccess = jwtUtil.createAccessToken(userId);
String newRefresh = jwtUtil.createRefreshToken(userId);
refreshTokenService.saveRefreshToken(userId, newRefresh);
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccess);
result.put("refreshToken", newRefresh);
return result;
}
}拦截器负责所有业务接口的统一鉴权。流程为:取请求头中的 Authorization 字段,解析 AccessToken,检查 type 是否为 access,再查黑名单。任何一步失败都返回 401。这里建议用一个枚举或错误码区分「过期」和「无效」,方便前端决定是走刷新流程还是直接跳转登录页。
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private TokenBlacklistService blacklistService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
response.setStatus(401);
return false;
}
String token = auth.substring(7);
try {
Claims claims = jwtUtil.parse(token);
if (!"access".equals(claims.get("type", String.class))) {
response.setStatus(401);
return false;
}
if (blacklistService.isBlacklisted(claims.getId())) {
response.setStatus(401);
return false;
}
request.setAttribute("userId", Long.valueOf(claims.getSubject()));
return true;
} catch (ExpiredJwtException e) {
response.setStatus(401);
response.getWriter().write("{\"code\":4001,\"msg\":\"token expired\"}");
return false;
} catch (JwtException e) {
response.setStatus(401);
return false;
}
}
}最后别忘了注册拦截器,并把刷新接口、登录接口排除在拦截范围之外。
六、落地时的几个坑点
第一是并发刷新问题。多个请求同时发现 AccessToken 过期,都携带同一个 RefreshToken 去刷新,第一个请求成功后旧 RefreshToken 被拉黑,其余请求就会失败。常见解法是前端对刷新请求加锁排队,或者后端在拉黑前留一个很短的宽限期,同一 RefreshToken 在几秒内重复使用仍返回同样的新 Token(可以按旧 jti 缓存新 Token 结果实现)。
第二是注销接口的实现。用户主动退出时,要把当前 AccessToken 按剩余有效期拉黑,同时删除 Redis 中的 RefreshToken。两者缺一不可,只删 RefreshToken 的话,未过期的 AccessToken 依然能访问接口。
第三是时钟偏移。集群部署时各机器时间不同步可能导致 Token 提前或延后过期,建议服务端统一使用 NTP 校时,并在解析时设置少量时钟偏移容忍,例如 setAllowedClockSkewSeconds(30),可以规避大部分误判。
整套方案下来,AccessToken 短周期轮换限制了泄露的损失,RefreshToken 一次性使用加 Redis 校验堵住了重放攻击,黑名单机制则补上了 JWT 无法主动作废的天然短板。代码量不大,但每一环都对应一个明确的安全诉求,可以直接迁移到绝大多数 Spring Boot 项目中使用。
Spring Boot整合JWTToken刷新机制JWT黑名单修改时间:2026-09-16 22:08:57