☰
SpringBoot整合JWT实战:从依赖选型到主动失效的完整落地指南
2026/10/15 19:53:15 网站建设 项目流程

1. 从Session到Token:为什么现代接口鉴权绕不开JWT

做过几年后端的人大概都有这样的经历:早期用Session做登录态管理,单体应用跑得好好的,一旦服务拆分或者前端换成移动端、小程序,问题就来了。Session依赖服务端存储,多实例部署时要么做Session共享,要么用粘性会话,运维复杂度直线上升。更麻烦的是跨域场景,Cookie的传递规则在App和小程序里根本不适用。

JWT(JSON Web Token)就是在这个背景下被广泛采用的。它的核心思路很直接:把用户身份信息经过签名后编码成一个字符串,交给客户端保存,服务端不存任何状态,每次请求带上这个字符串,服务端验签即可确认身份。无状态、可跨域、自带过期时间,这三点让它在前后端分离架构里几乎成了标配。

但我要先说一个很多人踩过的坑:JWT不是银弹,它解决的是"服务端不存Session"的问题,代价是"Token一旦签发就无法主动失效"。这个特性决定了你在设计登出、踢人下线、权限变更这些功能时,必须额外引入黑名单或版本号机制。很多教程只讲怎么签发和验证,闭口不谈这些,结果上线后才发现"用户改了密码,旧Token还能用"这种尴尬情况。

这篇内容我会围绕SpringBoot整合JWT的完整落地过程展开,从依赖选型、密钥管理、拦截器设计,到刷新Token、黑名单、并发验证这些实际开发中绕不开的问题,都会给出可复现的代码和配置。适合已经写过SpringBoot接口、但对鉴权体系还没有完整认知的开发者,也适合正在做技术选型、想搞清楚JWT边界在哪里的朋友。

2. 依赖选型与密钥管理:别一上来就抄pom

2.1 jjwt、java-jwt、nimbus-jose-jwt到底选哪个

Java生态里操作JWT的库主要有三个流派,我列个表对比一下实际使用感受:

库名维护方API风格依赖体积适合场景
jjwt社区链式Builder较小,需分模块引入大多数SpringBoot项目
java-jwtAuth0静态方法小需要与Auth0生态配合
nimbus-jose-jwt社区偏底层较大需要完整JOSE规范支持

我个人的选择是jjwt,原因是它的API对SpringBoot开发者最友好,而且0.11.x版本之后把模块拆分了,你可以只引入需要的部分。但这里有个细节:jjwt从0.10开始API有过一次大改,网上大量教程还是老版本的Jwts.parser().setSigningKey()写法,直接抄会编译不过。新版本的正确姿势是Jwts.parserBuilder().setSigningKey().build()。

pom里需要引入三个模块,缺一不可:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <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>

jjwt-api是编译期用的接口,jjwt-impl和jjwt-jackson是运行期实现,scope设为runtime可以避免代码里误引用实现类。这个拆分设计其实是在提醒你:面向接口编程,别把实现细节泄漏到业务代码里。

2.2 密钥不能硬编码在代码里

这是我最想强调的一点。我见过太多项目把签名密钥直接写成private static final String SECRET = "abcdefg123456",然后提交到代码仓库。这个密钥一旦泄漏,任何人都能伪造Token,整个鉴权体系形同虚设。

正确的做法是把密钥放到配置中心或环境变量里,通过@Value或@ConfigurationProperties注入。而且密钥长度有硬性要求:HS256算法要求密钥至少256位,也就是32个字节。如果你用一个短字符串,jjwt会直接抛WeakKeyException。很多人第一次遇到这个异常会懵,其实就是密钥太短了。

jwt: secret: ${JWT_SECRET:默认开发环境密钥至少32字节长度哦} expire-minutes: 120 refresh-expire-days: 7

注意这里用了${JWT_SECRET:默认值}的写法,生产环境通过环境变量注入真实密钥,开发环境用默认值兜底。默认值也要保证32字节以上,否则本地启动就报错。

提示:密钥轮换是个容易被忽略的问题。如果你的密钥泄漏了需要更换,所有已签发的Token都会失效,用户会被强制登出。所以要么接受这个代价,要么设计双密钥机制(新旧密钥同时可验证,逐步淘汰旧密钥)。

2.3 生成密钥的正确方式

不要用"mysecret".getBytes()这种写法,字符编码在不同平台可能不一致。推荐用Keys工具类生成符合算法要求的密钥:

import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

Keys.hmacShaKeyFor会自动校验密钥长度,不够长直接抛异常,相当于帮你做了一次安全检查。这个细节看似小,但能避免很多"本地能跑、线上报错"的诡异问题。

3. Token的签发:Claim里到底该放什么

3.1 标准Claim与自定义Claim的取舍

JWT的payload里可以放三类信息:注册声明(iss、sub、exp、iat等标准字段)、公共声明、私有声明。很多人喜欢把用户ID、用户名、角色、权限列表全塞进去,觉得这样服务端就不用查库了。这个做法在简单场景下没问题,但有几个隐患:

  • Token体积膨胀:权限列表如果很长,Token可能超过几KB,每次请求都带着走,浪费带宽。
  • 信息过期:Token签发后内容就固定了,用户权限变更后旧Token里的权限还是旧的。
  • 敏感信息泄漏:JWT的payload只是Base64编码,不是加密,任何人都能解出来看。

我的建议是:payload里只放用户唯一标识(userId)和必要的元信息(如tokenVersion),具体权限每次请求时从缓存或数据库查。这样Token体积小,权限变更也能实时生效。

public String generateToken(Long userId, int tokenVersion) { Date now = new Date(); Date expiry = new Date(now.getTime() + expireMinutes * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("ver", tokenVersion) .setIssuedAt(now) .setExpiration(expiry) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); }

这里的tokenVersion是个关键设计。用户表里加一个token_version字段,每次改密码或强制下线时把这个字段加一,验证Token时比对版本号,不一致就拒绝。这样既保留了JWT无状态的优点,又实现了"主动失效"的能力。

3.2 过期时间的设置逻辑

Access Token和Refresh Token要分开设置过期时间,这是行业惯例:

  • Access Token:短过期,一般15分钟到2小时。它频繁传输,泄漏风险高,所以要短。
  • Refresh Token:长过期,一般7天到30天。它只在刷新接口用,传输频率低,可以长一些。

我见过有人把Access Token设成7天,图省事不用刷新。结果就是Token泄漏后攻击者能用一周,而且用户改密码也没法立即失效。过期时间本质上是安全性和用户体验的权衡,短过期+自动刷新才是正解。

public String generateRefreshToken(Long userId, int tokenVersion) { Date now = new Date(); Date expiry = new Date(now.getTime() + refreshExpireDays * 24L * 60 * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("ver", tokenVersion) .claim("type", "refresh") .setIssuedAt(now) .setExpiration(expiry) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); }

注意我加了个type字段区分两种Token。验证时必须检查type,防止有人拿Refresh Token当Access Token用。这个漏洞在很多实现里都存在,攻击者拿到Refresh Token后直接访问业务接口,如果服务端不区分类型就中招了。

3.3 签发接口的完整实现

登录接口的流程是:校验用户名密码 → 查用户信息 → 生成双Token → 返回给客户端。

@PostMapping("/login") public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) { User user = userService.findByUsername(dto.getUsername()); if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.fail("用户名或密码错误"); } String accessToken = jwtService.generateToken(user.getId(), user.getTokenVersion()); String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTokenVersion()); LoginVO vo = new LoginVO(); vo.setAccessToken(accessToken); vo.setRefreshToken(refreshToken); vo.setExpiresIn(expireMinutes * 60); return Result.ok(vo); }

这里有个安全细节:用户名不存在和密码错误要返回同样的提示,否则攻击者可以通过错误信息枚举出哪些用户名是有效的。这个原则叫"防用户枚举",虽然和JWT没直接关系,但登录接口是鉴权体系的门面,必须一起考虑。

4. 拦截器与过滤器:验证逻辑放在哪一层

4.1 Filter和Interceptor的区别与选择

SpringBoot里做请求拦截有两个选择:Filter和Interceptor。它们的执行时机不同:

  • Filter:Servlet容器级别,在DispatcherServlet之前执行,拿不到SpringMVC的Handler信息。
  • Interceptor:SpringMVC级别,在HandlerMapping之后、HandlerAdapter之前执行,能拿到即将执行的方法信息。

对于JWT验证,我推荐用Interceptor,原因是它能拿到HandlerMethod,可以方便地实现"某些接口放行、某些接口需要登录"的细粒度控制。比如用注解标记需要鉴权的接口,Interceptor里检查注解即可。

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod method = (HandlerMethod) handler; // 检查方法或类上是否有@RequireLogin注解 RequireLogin annotation = method.getMethodAnnotation(RequireLogin.class); if (annotation == null) { annotation = method.getBeanType().getAnnotation(RequireLogin.class); } if (annotation == null) { return true; // 不需要登录,放行 } String token = extractToken(request); if (token == null) { writeUnauthorized(response, "缺少Token"); return false; } try { Claims claims = jwtService.parseToken(token); Long userId = Long.valueOf(claims.getSubject()); Integer ver = claims.get("ver", Integer.class); // 校验版本号 if (!userService.checkTokenVersion(userId, ver)) { writeUnauthorized(response, "Token已失效"); return false; } // 把用户信息放入ThreadLocal,供后续业务使用 UserContext.set(userId); return true; } catch (ExpiredJwtException e) { writeUnauthorized(response, "Token已过期"); return false; } catch (JwtException e) { writeUnauthorized(response, "Token无效"); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); // 必须清理,防止线程池复用导致数据串号 } }

afterCompletion里的UserContext.clear()是必须的。Tomcat用线程池处理请求,线程会被复用,如果不清理ThreadLocal,下一个请求可能读到上一个用户的ID。这个bug很隐蔽,本地测试单线程跑不出来,线上并发一上来就出问题。

4.2 注册拦截器与放行路径

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/api/auth/login", "/api/auth/refresh", "/api/public/**", "/error" ); } }

放行路径要仔细梳理。/error必须放行,否则鉴权失败时SpringBoot转发到错误页会被再次拦截,导致死循环。静态资源路径也要放行,不然前端页面都加载不出来。

4.3 从请求头提取Token的规范

Token放在Authorization头里,格式是Bearer <token>,这是OAuth2的标准做法。提取时要处理大小写和空格:

private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { return null; } return header.substring(7).trim(); }

有些前端会把Token放在自定义头或者query参数里,我建议统一用Authorization头。query参数会出现在访问日志里,容易泄漏;自定义头虽然也行,但不如标准头通用。

5. 刷新Token与主动失效:JWT的短板怎么补

5.1 双Token刷新流程

Access Token过期后,客户端拿Refresh Token去换新的Access Token。刷新接口本身不需要鉴权(因为Access Token已经过期了),但要验证Refresh Token的有效性。

@PostMapping("/refresh") public Result<LoginVO> refresh(@RequestBody RefreshDTO dto) { Claims claims; try { claims = jwtService.parseToken(dto.getRefreshToken()); } catch (JwtException e) { return Result.fail("Refresh Token无效"); } // 必须校验type,防止Access Token被拿来刷新 if (!"refresh".equals(claims.get("type", String.class))) { return Result.fail("Token类型错误"); } Long userId = Long.valueOf(claims.getSubject()); Integer ver = claims.get("ver", Integer.class); if (!userService.checkTokenVersion(userId, ver)) { return Result.fail("Token已失效"); } User user = userService.findById(userId); String newAccess = jwtService.generateToken(userId, user.getTokenVersion()); // Refresh Token可以选择续期或保持不变 String newRefresh = jwtService.generateRefreshToken(userId, user.getTokenVersion()); LoginVO vo = new LoginVO(); vo.setAccessToken(newAccess); vo.setRefreshToken(newRefresh); return Result.ok(vo); }

这里有个策略选择:Refresh Token要不要每次刷新都换新的。换新的更安全(旧Refresh Token立即失效),但实现复杂,需要维护Refresh Token的黑名单。不换的话实现简单,但Refresh Token在有效期内一直可用。我一般建议换新,配合Redis记录已使用的Refresh Token,防止重放。

5.2 基于版本号的强制下线

前面提到的tokenVersion机制,具体实现是在用户表加字段,验证时比对:

public boolean checkTokenVersion(Long userId, Integer tokenVersion) { if (tokenVersion == null) { return false; } // 优先从缓存读,缓存没有再查库 Integer current = cache.get("user:ver:" + userId, Integer.class); if (current == null) { User user = userMapper.selectById(userId); if (user == null) { return false; } current = user.getTokenVersion(); cache.set("user:ver:" + userId, current, Duration.ofMinutes(30)); } return current.equals(tokenVersion); }

改密码、管理员踢人、用户主动登出时,把token_version加一,所有旧Token立即失效。这个方案的好处是不用维护Token黑名单,代价是每次验证要读一次版本号(可以用缓存优化)。

注意:版本号缓存要设置合理的过期时间。如果缓存30分钟,那用户改密码后最多30分钟旧Token才失效。对安全性要求高的场景,缓存时间要缩短,或者改密码时主动删除缓存。

5.3 登出接口的实现

严格来说JWT没有"登出"概念,因为服务端不存状态。但业务上用户点了登出,你得让Token失效。用版本号机制就能实现:

@PostMapping("/logout") @RequireLogin public Result<Void> logout() { Long userId = UserContext.get(); userService.incrementTokenVersion(userId); cache.delete("user:ver:" + userId); return Result.ok(); }

登出后该用户所有设备上的Token都失效了。如果只想让当前设备登出,那就需要维护单个Token的黑名单,复杂度更高。大多数场景下全设备登出是可以接受的。

6. 实测中那些文档不会告诉你的坑

6.1 时钟偏移导致Token验证失败

JWT的exp和iat是基于服务器时间的。如果签发Token的服务器和验证Token的服务器时间不一致,可能出现"刚签发的Token就过期"或者"过期Token还能用"的情况。分布式部署时这个问题尤其明显。

jjwt提供了时钟偏移容忍度设置:

Jwts.parserBuilder() .setSigningKey(secretKey) .setAllowedClockSkewSeconds(60) // 容忍60秒偏差 .build() .parseClaimsJws(token);

设置60秒容忍度能解决大部分时钟同步问题。但根本方案还是保证所有服务器用NTP同步时间。

6.2 Base64URL编码的坑

JWT用的是Base64URL编码,不是标准Base64。区别在于+换成-,/换成_,并且去掉末尾的=。如果你自己手动解析Token,用标准Base64解码会失败。用jjwt的话它内部处理了,不用操心。但如果你要把Token传给前端解析,记得告诉前端用Base64URL。

6.3 Token体积与请求头大小限制

Tomcat默认的请求头大小限制是8KB。如果你的Token里塞了太多Claim,加上其他请求头可能超限,导致请求被拒。我实测过一个塞了20个权限的Token大约1.5KB,还算安全。但如果你的权限列表有上百个,就要考虑精简Claim了。

6.4 并发刷新导致的Token覆盖

客户端多个请求同时发现Access Token过期,同时发起刷新,可能拿到多个新Token,后发的覆盖先发的,导致部分请求用旧Token失败。解决方案是客户端加锁,保证同一时间只有一个刷新请求。服务端也可以做幂等,但客户端加锁更简单。

6.5 密钥长度不足的报错

前面提过,HS256要求密钥至少256位。如果你用"secret"这种短字符串,启动时就会抛WeakKeyException: The signing key's size is 48 bits which is not secure enough for the HS256 algorithm。解决办法就是换一个32字节以上的密钥。这个报错信息其实很明确,但很多人不看异常信息,直接搜"jjwt报错",绕一圈才找到原因。

7. 一套可直接复用的JWT工具类设计

把前面的逻辑封装成一个工具类,方便在项目里复用:

@Component public class JwtService { private final SecretKey secretKey; private final long expireMinutes; private final long refreshExpireDays; public JwtService(@Value("${jwt.secret}") String secret, @Value("${jwt.expire-minutes}") long expireMinutes, @Value("${jwt.refresh-expire-days}") long refreshExpireDays) { this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expireMinutes = expireMinutes; this.refreshExpireDays = refreshExpireDays; } public String generateToken(Long userId, int tokenVersion) { return buildToken(userId, tokenVersion, "access", expireMinutes * 60 * 1000); } public String generateRefreshToken(Long userId, int tokenVersion) { return buildToken(userId, tokenVersion, "refresh", refreshExpireDays * 24 * 60 * 60 * 1000); } private String buildToken(Long userId, int tokenVersion, String type, long ttlMillis) { Date now = new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("ver", tokenVersion) .claim("type", type) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + ttlMillis)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey) .setAllowedClockSkewSeconds(60) .build() .parseClaimsJws(token) .getBody(); } }

这个类的设计要点:构造时初始化密钥和过期时间,避免每次调用都重新计算;buildToken私有方法统一签发逻辑,减少重复代码;parseToken统一设置时钟偏移容忍度。

8. 关于JWT适用边界的一点个人看法

JWT不是所有场景都适合。如果你的系统是单体应用,用户量不大,Session完全够用,没必要为了"无状态"而引入JWT的复杂度。JWT真正的价值场景是:多服务共享鉴权、跨域频繁、客户端类型多样(Web、App、小程序)。

另外,JWT的安全性高度依赖密钥管理和传输安全。没有HTTPS的情况下,Token在网络上明文传输,任何中间人都能截获。所以上JWT之前,先确保你的接口走的是HTTPS。这个前提条件比JWT本身的实现细节重要得多。

我在实际项目里踩过最深的坑,不是代码写错,而是对"Token无法主动失效"这个特性认识不足,导致改密码后旧Token还能用。后来加了版本号机制才解决。所以如果你正准备上JWT,建议一开始就把版本号字段设计进去,别等出了问题再补,那时候改造成本会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询