1. 从Session到Token为什么现代接口鉴权绕不开JWT做过几年后端的人大概都有这样的经历早期用Session做登录态管理单体应用跑得好好的一旦服务拆分或者前端换成移动端、小程序问题就来了。Session依赖服务端存储多实例部署时要么做Session共享要么用粘性会话运维复杂度直线上升。更麻烦的是跨域场景Cookie的传递规则在App和小程序里根本不适用。JWTJSON Web Token就是在这个背景下被广泛采用的。它的核心思路很直接把用户身份信息经过签名后编码成一个字符串交给客户端保存服务端不存任何状态每次请求带上这个字符串服务端验签即可确认身份。无状态、可跨域、自带过期时间这三点让它在前后端分离架构里几乎成了标配。但我要先说一个很多人踩过的坑JWT不是银弹它解决的是服务端不存Session的问题代价是Token一旦签发就无法主动失效。这个特性决定了你在设计登出、踢人下线、权限变更这些功能时必须额外引入黑名单或版本号机制。很多教程只讲怎么签发和验证闭口不谈这些结果上线后才发现用户改了密码旧Token还能用这种尴尬情况。这篇内容我会围绕SpringBoot整合JWT的完整落地过程展开从依赖选型、密钥管理、拦截器设计到刷新Token、黑名单、并发验证这些实际开发中绕不开的问题都会给出可复现的代码和配置。适合已经写过SpringBoot接口、但对鉴权体系还没有完整认知的开发者也适合正在做技术选型、想搞清楚JWT边界在哪里的朋友。2. 依赖选型与密钥管理别一上来就抄pom2.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 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 /dependencyjjwt-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 ResultLoginVO 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。它们的执行时机不同FilterServlet容器级别在DispatcherServlet之前执行拿不到SpringMVC的Handler信息。InterceptorSpringMVC级别在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 ResultLoginVO 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 ResultVoid 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 keys 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建议一开始就把版本号字段设计进去别等出了问题再补那时候改造成本会高很多。