导读:本期聚焦于梁博渊创作的《Spring Boot 整合 JWT 后 Token 过期怎么办?手把手实现双 Token 刷新与黑名单机制》,敬请观看详情。Token 过期后强制用户重新登录,是前后端分离项目里最影响体验的问题之一。本文基于 Spring Boot 完整演示如何引入双 Token 机制,通过短有效期的 AccessToken 配合长有效期的 RefreshToken 实现无感刷新,同时利用 Redis 存储黑名单与刷新令牌,解决 Token 注销后仍可访问的安全隐患。文章包含依赖配置、工具类编写、拦截器校验、接口设计以及并发刷新、旧 Token 复用等常见坑点的应对方案,可直接落地到实际项目中。

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

Spring Boot 整合 JWT 后 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>
&ltlt;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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0916/58219.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。