恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot整合JWT实战:从Session到Token的认证方案与避坑指南
首页
资讯中心
/
SpringBoot整合JWT实战:从Session到Token的认证方案与避坑指南
SpringBoot整合JWT实战:从Session到Token的认证方案与避坑指南
发布时间:2026/10/14 17:14:13
1. 从Session到Token为什么SpringBoot项目要换一套身份验证思路做过几年Web开发的人大概都有这样的经历项目初期用Session存登录状态本地跑得好好的一上线就出问题。用户量一上来服务器扛不住加一台机器做负载均衡用户刚登录完下一个请求打到另一台机器上又提示未登录。这种登录状态飘忽不定的体验是很多团队从单体应用走向分布式架构时踩的第一个大坑。传统Session机制的核心问题在于状态存在服务端。服务端要记住每个登录用户就得占内存、要同步、要处理过期清理。单机时代这没什么一旦服务横向扩展Session共享就成了必须解决的工程问题。常见的解法是用Redis做集中式Session存储但这又引入了新的依赖——Redis挂了全站登录失效。JWTJSON Web Token换了个思路把状态交给客户端保管。服务端签发一个经过签名的Token客户端每次请求带上它服务端只需要验证签名是否有效、是否过期不需要存储任何会话数据。这就是所谓的无状态认证。对于SpringBoot项目来说整合JWT意味着你的接口层可以做到真正的水平扩展——加多少台机器都不需要担心Session同步问题。不过我得先把话说在前头JWT不是银弹。它解决了分布式会话的问题但也带来了新的挑战比如Token无法主动失效、续期机制需要额外设计、签名密钥泄露后果严重等。这篇文章不会只给你一段能跑的代码而是把整合过程中每个决策背后的逻辑讲清楚让你知道什么场景该用、什么场景不该用、用了之后怎么避坑。这篇文章适合已经写过SpringBoot接口、对拦截器和过滤器有基本概念、但还没系统做过Token认证的开发者。如果你连SpringBoot的Controller都没写过建议先把基础补一补再来看。下面我会从依赖选型开始一步步走到完整的认证流程中间穿插我在实际项目中踩过的坑和总结的经验。2. 依赖选型与密钥策略整合前的两个关键决策2.1 jjwt、java-jwt还是nimbus三选一的实际考量Java生态里操作JWT的库不止一个主流的有三个jjwt、java-jwtAuth0出品和nimbus-jose-jwt。很多人随便挑一个就用结果后期遇到问题才发现选型不合适。我把这三个库的核心差异列出来方便你根据项目情况判断。对比维度jjwtjava-jwtnimbus-jose-jwtAPI风格链式调用流畅链式调用简洁偏底层较繁琐依赖体积轻量轻量较重算法支持HMAC/RSA/ECDSAHMAC/RSA/ECDSA最全含JWE加密文档友好度好很好一般社区活跃度高高高适合场景常规认证常规认证需要加密载荷对于绝大多数SpringBoot项目的登录认证场景jjwt和java-jwt都够用。我个人更倾向jjwt原因是它的模块拆分清晰api、impl、jackson三个jar你可以按需引入而且对SpringBoot的适配比较自然。java-jwt的API更简洁如果你追求代码短它也不错。nimbus功能最全但除非你需要JWE对Token内容加密而不只是签名否则没必要上这么重的库。这里有个版本坑要提醒jjwt在0.10.x和0.11.x之间API有不小变化。0.9.x用的是Jwts.parser().setSigningKey()0.11.x改成了Jwts.parserBuilder().setSigningKey().build()。网上很多老教程还是0.9.x的写法你直接抄会编译不过。我下面统一用0.11.x的写法。引入依赖时jjwt需要三个坐标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注意后两个的scope是runtime因为编译期你只需要api里的接口实现类在运行时才加载。这个细节很多人忽略导致打包时漏掉实现类运行时报Unable to find an implementation for JwtBuilder。2.2 签名密钥怎么生成才安全JWT的安全性完全建立在签名密钥之上。密钥一旦泄露任何人都能伪造Token。我见过最离谱的做法是把密钥写成secret或者123456这种项目上线等于把大门钥匙挂在门锁上。HMAC-SHA256算法要求密钥长度至少256位32字节。你可以用Java代码生成一个安全的随机密钥import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; SecretKey key Keys.secretKeyFor(SignatureAlgorithm.HS256); String base64Key Base64.getEncoder().encodeToString(key.getEncoded()); System.out.println(base64Key);生成的这个Base64字符串就是你的密钥把它放到配置文件里通过Value注入。千万不要硬编码在代码里提交到版本库。正确做法是放在application.yml中并且这个文件在.gitignore里排除或者用环境变量注入。jwt: secret: ${JWT_SECRET:默认开发环境密钥} expire-minutes: 120 issuer: my-app用${JWT_SECRET:默认值}的写法生产环境通过环境变量注入真实密钥开发环境用默认值既方便又安全。这个技巧我在多个项目里都用过比单纯写死配置灵活得多。还有一个容易被忽略的点密钥轮换。如果你的密钥用了很久没换理论上存在被暴力破解的风险虽然256位密钥暴力破解在算力上不现实但密钥可能通过其他途径泄露。建议设计一个密钥版本机制Token头部带上密钥版本号验证时根据版本选择对应密钥这样可以在不强制所有用户重新登录的情况下平滑轮换密钥。这个机制在用户量大的系统里很有价值但实现起来稍复杂小项目可以先不做心里有数即可。3. Token的生成与解析把签名、载荷、过期时间一次讲透3.1 一个JWT到底长什么样在写代码之前得先搞清楚JWT的结构。一个JWT字符串由三部分组成用点号分隔Header.Payload.Signature。Header是Base64编码的JSON描述签名算法和类型比如{alg:HS256,typ:JWT}。Payload也是Base64编码的JSON存放实际数据比如用户ID、角色、过期时间。Signature是对前两部分的签名用密钥和算法计算得出。这里有个极其重要的认知Header和Payload只是Base64编码不是加密。任何人拿到Token都能解码看到里面的内容。所以绝对不要在Payload里放密码、手机号、身份证号等敏感信息。Payload里只放用户标识如userId和必要的非敏感元数据。这个坑我见过有人踩把用户完整信息塞进Token结果Token一泄露用户信息全暴露。Payload里有几个标准字段Claim值得了解subsubject主题通常放用户IDississuer签发者expexpiration过期时间Unix时间戳iatissued at签发时间jtiJWT IDToken唯一标识可用于防重放自定义字段可以随便加但建议加个前缀避免和标准字段冲突比如uid、role。3.2 生成Token的完整代码与参数计算下面是一个封装好的Token工具类核心方法Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-minutes}) private long expireMinutes; Value(${jwt.issuer}) private String issuer; private SecretKey getKey() { byte[] keyBytes Base64.getDecoder().decode(secret); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(Long userId, String username) { Date now new Date(); Date expiry new Date(now.getTime() expireMinutes * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuer(issuer) .setIssuedAt(now) .setExpiration(expiry) .setId(UUID.randomUUID().toString()) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } }这里有几个参数需要解释。expireMinutes * 60 * 1000是把分钟转成毫秒因为Date构造函数接收的是毫秒。过期时间设多长是个权衡设太短用户频繁被踢出登录体验差设太长Token泄露后的风险窗口就大。常规做法是访问Token设2小时配合刷新Token有效期7天来续期。如果你的系统安全要求高可以缩短到30分钟。setId(UUID.randomUUID().toString())给每个Token一个唯一ID这个在实现主动失效时有用——你可以维护一个黑名单把需要提前失效的Token的jti存进去验证时检查。不过黑名单又引入了状态存储和JWT无状态的初衷有点矛盾所以只在对安全性要求极高的场景用。3.3 解析Token时最容易忽略的异常处理解析Token的代码看起来简单但异常处理才是重点public Claims parseToken(String token) { try { return Jwts.parserBuilder() .setSigningKey(getKey()) .requireIssuer(issuer) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new BizException(Token已过期请重新登录); } catch (SignatureException e) { throw new BizException(Token签名无效); } catch (MalformedJwtException e) { throw new BizException(Token格式错误); } catch (JwtException e) { throw new BizException(Token验证失败); } }ExpiredJwtException、SignatureException、MalformedJwtException这三个是最常见的。很多人只catch一个JwtException结果前端拿到的错误信息全是验证失败无法区分是过期还是被篡改导致前端没法做针对性处理比如过期就跳登录页篡改就提示异常。把异常分类处理是提升接口友好度的关键细节。requireIssuer(issuer)这行也值得说。它会校验Token的iss字段是否匹配防止别人用其他系统签发的Token来访问你的接口如果多个系统共用密钥的话。加上这行多系统隔离更安全。4. 拦截器与过滤器把Token校验嵌进请求链路4.1 拦截器还是过滤器先搞清楚执行时机SpringBoot里做请求拦截有两个选择Filter过滤器和Interceptor拦截器。很多人纠结用哪个其实搞清楚它们的执行时机就明白了。Filter是Servlet规范的一部分在请求进入DispatcherServlet之前执行能拿到原始的HttpServletRequest和HttpServletResponse。Interceptor是SpringMVC的组件在DispatcherServlet之后、Controller之前执行能拿到HandlerMethod信息。对于JWT校验两者都能做。我倾向用Interceptor原因是它能方便地注入Spring容器里的Bean比如JwtUtil而且能拿到请求映射的方法信息便于做细粒度控制比如某些方法需要特定角色。Filter虽然也能通过构造注入但配置起来稍麻烦。不过有个场景必须用Filter如果你需要在请求体被读取之前做校验或者要包装Request/ResponseFilter更合适。常规的Token校验用Interceptor足够了。4.2 拦截器的完整实现与放行策略先定义一个拦截器Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } String token extractToken(request); if (token null) { writeError(response, 401, 缺少认证Token); return false; } try { Claims claims jwtUtil.parseToken(token); // 把用户信息放入请求上下文供Controller使用 request.setAttribute(userId, claims.getSubject()); request.setAttribute(username, claims.get(username)); return true; } catch (BizException e) { writeError(response, 401, e.getMessage()); return false; } } private String extractToken(HttpServletRequest request) { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { return header.substring(7); } return null; } private void writeError(HttpServletResponse response, int code, String msg) throws IOException { response.setStatus(code); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\: code ,\message\:\ msg \}); } }这里有几个实战细节。第一HttpMethod.OPTIONS的放行不能少。浏览器跨域请求前会先发一个OPTIONS预检请求这个请求不带Token如果不放行跨域场景下所有请求都会失败。这个坑我在前后端分离项目里踩过排查了半天才发现是预检请求被拦了。第二Token从Authorization头里取格式是Bearer xxx。substring(7)是因为Bearer 正好7个字符。这个格式是OAuth2的标准建议遵守别自己发明从自定义头里取。第三校验通过后把用户信息放进request.setAttributeController里就能通过RequestAttribute拿到避免每个接口都重新解析Token。然后注册拦截器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/register, /error, /swagger-ui/**, /v3/api-docs/** ); } }excludePathPatterns里必须放行登录、注册接口否则用户没法登录。/error也要放行不然出错时SpringBoot转发到错误页会被拦截导致真正的错误信息被401覆盖排查问题时会很痛苦。Swagger相关路径如果用了也要放行方便调试。4.3 跨域配置与Token的配合前后端分离项目里跨域是绕不开的。如果前端和后端不同源浏览器会拦截响应。SpringBoot里配置CORS有几种方式我推荐用CorsFilter或者WebMvcConfigurer.addCorsMappings。用addCorsMappings时有个坑allowCredentials(true)和allowedOrigins(*)不能同时用。如果前端需要携带Cookie虽然JWT通常放Header里但有些场景会用到就必须指定具体域名不能用通配符。这个限制是浏览器规范决定的不是SpringBoot的问题。Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }注意这里用的是allowedOriginPatterns而不是allowedOrigins。SpringBoot 2.4之后allowedOrigins(*)配合allowCredentials(true)会抛异常必须用allowedOriginPatterns。这个变化很多老教程没更新直接抄会报错。5. 刷新Token与主动失效JWT两大痛点的应对方案5.1 双Token机制访问Token与刷新Token的分工JWT最被诟病的一点是签发后无法撤销。用户登出、改密码、被管理员封禁理论上旧Token在过期前仍然有效。解决这个问题有两个思路一是缩短访问Token有效期配合刷新Token续期二是维护黑名单。双Token机制是主流方案。访问Token有效期短比如30分钟用于日常接口调用刷新Token有效期长比如7天只用于换取新的访问Token。访问Token过期后前端用刷新Token调刷新接口拿到新的访问Token继续用。这样即使访问Token泄露攻击窗口也只有30分钟。刷新接口的实现PostMapping(/api/auth/refresh) public Result refresh(RequestBody RefreshRequest req) { Claims claims jwtUtil.parseRefreshToken(req.getRefreshToken()); Long userId Long.valueOf(claims.getSubject()); // 可选校验用户是否仍有效、刷新Token是否在黑名单 String newAccessToken jwtUtil.generateToken(userId, claims.get(username, String.class)); return Result.success(newAccessToken); }刷新Token的生成和访问Token类似但有效期更长且Payload里加个type: refresh标记防止访问Token被拿来当刷新Token用。验证时要检查这个type字段。这里有个设计决策刷新Token要不要也设过期要。而且刷新Token过期后必须重新登录。有些实现让刷新Token永不过期这是危险的等于给了攻击者一个永久后门。5.2 黑名单方案用Redis实现Token主动失效双Token解决了过期问题但没解决主动失效。用户登出后访问Token在剩余有效期内仍然能用。要彻底解决就得引入黑名单。思路很简单登出时把当前Token的jti或者Token本身存入Redis设置过期时间为Token的剩余有效期。每次校验Token时先查Redis看是否在黑名单里。public void logout(String token) { Claims claims jwtUtil.parseToken(token); String jti claims.getId(); long ttl claims.getExpiration().getTime() - System.currentTimeMillis(); if (ttl 0) { redisTemplate.opsForValue().set(jwt:blacklist: jti, 1, ttl, TimeUnit.MILLISECONDS); } }校验时加一步if (Boolean.TRUE.equals(redisTemplate.hasKey(jwt:blacklist: claims.getId()))) { throw new BizException(Token已失效); }这个方案引入了Redis依赖和JWT无状态的初衷有冲突但这是安全性和无状态之间的必要权衡。如果你的系统对安全性要求不高可以不做黑名单接受登出后Token在过期前仍有效这个事实。但如果涉及资金、隐私等敏感操作黑名单是必须的。黑名单的存储量是个考量点。如果用户量大、登出频繁Redis里会积累大量key。不过因为每个key都设了过期时间会自动清理实际占用可控。你也可以只对敏感操作的Token做黑名单普通查询接口不做减少存储压力。5.3 一个容易被忽略的细节时钟偏移JWT的过期校验依赖服务器时间。如果签发Token的服务器和验证Token的服务器时间不一致就会出现刚签发的Token就过期或者过期Token还能用的诡异现象。分布式部署时这个问题尤其明显。jjwt提供了setAllowedClockSkewSeconds方法来容忍一定的时间偏差Jwts.parserBuilder() .setSigningKey(getKey()) .setAllowedClockSkewSeconds(60) .build() .parseClaimsJws(token);设置60秒的容忍度能覆盖大多数服务器间的时间偏差。但根本解法还是保证所有服务器的时间同步用NTP服务。这个细节在单机开发时完全感知不到一上集群就可能出问题提前知道能省不少排查时间。6. 从登录到鉴权一条完整的请求链路复盘6.1 登录接口该返回什么登录接口是整个认证流程的起点。用户提交用户名密码服务端校验通过后签发Token返回。返回结构建议包含访问Token、刷新Token和过期时间PostMapping(/api/auth/login) public ResultLoginVO login(RequestBody LoginDTO dto) { User user userService.findByUsername(dto.getUsername()); if (user null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.fail(用户名或密码错误); } String accessToken jwtUtil.generateToken(user.getId(), user.getUsername()); String refreshToken jwtUtil.generateRefreshToken(user.getId(), user.getUsername()); LoginVO vo new LoginVO(accessToken, refreshToken, jwtUtil.getExpireSeconds()); return Result.success(vo); }注意密码校验用passwordEncoder.matches不要自己写MD5比较。Spring Security的BCryptPasswordEncoder是推荐方案它自带盐值比MD5安全得多。如果你没用Spring Security也可以单独引入spring-security-crypto依赖只用它的密码编码器。返回结构里带上expireSeconds前端可以根据这个值提前刷新Token避免请求打到后端才发现过期。这个细节能显著提升用户体验。6.2 前端如何配合Token的存储与携带后端做完了前端不配合也白搭。Token存哪里localStorage、sessionStorage还是Cookie各有优劣。localStorage持久化关闭浏览器后仍在但容易被XSS攻击读取。sessionStorage关闭标签页就清除安全性稍好但用户体验差。Cookie可以设HttpOnly防止JS读取但容易受CSRF攻击。我的建议是访问Token存内存Vuex/Redux刷新Token存HttpOnly Cookie。这样XSS拿不到刷新TokenCSRF因为访问Token在内存里不在Cookie里自动携带也防住了。这个方案安全性最高但实现稍复杂。如果项目安全要求一般存localStorage也能接受但一定要做好XSS防护。前端每次请求在Header里带上Tokenaxios.interceptors.request.use(config { const token store.state.accessToken; if (token) { config.headers.Authorization Bearer token; } return config; });响应拦截器里处理401自动尝试刷新Token或跳转登录页axios.interceptors.response.use( response response, async error { if (error.response error.response.status 401) { // 尝试用刷新Token换新Token失败则跳登录 await tryRefreshToken(); } return Promise.reject(error); } );这里要注意刷新Token的并发问题。如果多个请求同时返回401会触发多次刷新。需要加个锁或者标志位保证只刷新一次其他请求等待刷新完成后重试。这个坑在并发场景下必现提前处理好能省很多麻烦。6.3 完整链路串讲把整个流程串起来看用户登录服务端校验密码签发访问Token和刷新Token返回。前端存好Token后续请求在Header里带上访问Token。请求到达后端拦截器取出Token校验签名和过期时间通过则放行并把用户信息放入请求上下文Controller从上下文取用户ID处理业务。访问Token过期前端用刷新Token换新的访问Token继续请求。用户登出访问Token加入黑名单刷新Token删除或加入黑名单。这条链路里每个环节都有坑前面几节已经分别讲过。实际开发中建议先用Postman把登录、带Token访问、Token过期、刷新Token这几个场景都测一遍确认无误再接前端。后端接口没调通就联调前端问题会混在一起排查起来很痛苦。7. 那些文档不会告诉你的实战经验7.1 密钥管理的血泪教训我见过最危险的做法是把密钥写在代码里提交到Git。哪怕后来删了Git历史里还能翻出来。一旦仓库泄露比如开源时忘了清理密钥就暴露了。正确做法是用环境变量或配置中心代码里只留占位符。还有一个坑是密钥格式。jjwt的Keys.hmacShaKeyFor接收的是字节数组如果你直接把字符串的getBytes()传进去密钥长度可能不够256位取决于字符串长度会抛WeakKeyException。正确做法是生成时用Base64编码使用时Base64解码保证字节数达标。7.2 Token长度与性能的权衡JWT放在Header里每次请求都会带上。如果Payload里塞太多数据Token会很长增加网络传输开销。我见过有人把用户的所有权限列表塞进Token结果Token有2KB每个请求都传2KBQPS高的时候带宽压力明显。建议Payload里只放userId和必要的标识权限信息在服务端查可以缓存。Token长度控制在500字节以内比较理想。如果确实需要放较多数据考虑用JWE加密压缩但那是另一个复杂度了。7.3 关于无状态的理性认知JWT常被宣传为无状态认证但实际项目里很难做到完全无状态。黑名单要存Redis刷新Token要存Redis用户权限可能要查数据库。所以别被无状态这个词绑架该用状态存储的时候就用关键是理解每个设计的权衡。JWT真正的价值在于签名保证了Token不可伪造让服务端可以信任客户端传来的身份信息而不需要为每个会话维护服务端状态。这个价值在分布式场景下尤其明显。至于黑名单、刷新Token这些是在这个基础上为了满足安全需求做的补充不矛盾。7.4 测试时的一个实用技巧调试JWT相关问题时把Token贴到在线解码工具里看Payload内容能快速确认字段是否正确、过期时间是否合理。但注意生产环境的Token不要贴到任何在线工具里因为那等于把Token交给了第三方。本地开发用的Token无所谓生产Token要谨慎。可以自己写个简单的解码方法在本地跑不依赖外部工具。另外测试Token过期时别傻等2小时。把过期时间临时改成1分钟测完再改回来。或者写单元测试直接构造一个过期Token来验证解析逻辑。这个技巧能大幅提升调试效率。8. 写在最后一套能落地的认证方案长什么样把前面所有内容整合起来一套完整的SpringBoot JWT认证方案应该包含这些部分jjwt依赖引入、安全的密钥管理、Token生成与解析工具类、拦截器做统一校验、放行登录注册等白名单路径、双Token机制支持续期、Redis黑名单支持主动失效、跨域配置、前端Token存储与携带策略、401自动刷新逻辑。这套方案不是最简的但它是能扛住生产环境考验的。如果你只是做个练手项目可以砍掉黑名单和刷新Token只保留基础的生成、校验、拦截。但如果你要做的是要上线的系统建议把双Token和黑名单都加上安全性和用户体验都会好很多。我在多个项目里用过这套方案最大的体会是JWT的坑不在代码本身而在边界情况的处理。Token过期、时钟偏移、并发刷新、密钥轮换、跨域预检这些才是真正花时间的地方。把这些问题提前想清楚比事后救火强得多。希望这篇内容能帮你少走一些弯路把认证这块基础打扎实。