SpringBoot整合JWT实战:多端认证、Token刷新与安全细节

发布时间:2026/10/16 9:48:38
SpringBoot整合JWT实战:多端认证、Token刷新与安全细节
前阵子给一个内部系统做认证改造技术栈是SpringBoot单体服务前端有Web单页应用后来又接入小程序端接口得同时被浏览器、App、小程序调用。原来的做法是Session存登录态表面上看也能跑但越往后越别扭。正好借着重写登录模块的机会我把SpringBoot整合JWT这条路完整走了一遍中间踩了不少坑也把认证闭环的几个关键环节想清楚了。这篇就聊聊我在实际项目里的选型思路、落地代码、以及那些网上教程很少提到的细节给正在做同样改造的同学一个参考。1. 为什么我最终选了JWT认证方案选择的业务推演很多人上来就问“JWT怎么写”但我更建议先想清楚“为什么要用JWT”。技术选型不是越新越好而是看它在你的业务场景里解决什么问题。我当时权衡了大概一周最后才把方案定下来。1.1 Session方案在前后端分离下暴露的三个核心痛点第一个是跨域携带问题。Session依赖CookieCookie受浏览器同源策略限制当前端域名和后端接口域名不一致时请求默认不带Cookie。要跨域携带就得开启CORS withCredentials但开了之后CSRF攻击面也跟着上来安全配置会复杂很多。第二个是多端适配问题。小程序和App不是浏览器环境压根没有Cookie机制。虽然可以在请求里手动塞一个Session ID但那本质上跟自造token没什么区别还不如直接用规范统一的JWT。第三个是存储成本问题。每个登录用户都在服务端保存一份Session用户量小的时候无所谓一旦并发上来要么把Session塞进内存导致多实例无法共享要么引入Redis或者Session集群额外增加一套基础设施要维护。1.2 JWT解决了哪些问题又带来了哪些新麻烦JWT的核心思路是把用户身份信息经过签名后直接交给客户端保存服务端不再维护会话状态。请求进来时从Authorization头里取出token验签通过就认定身份合法。这个方案天然跨域名、跨端只要客户端能保存一串字符串就能完成鉴权Session那三个痛点基本都能绕开。但JWT不是银弹它也有自己的麻烦。最典型的是主动失效难服务端没有状态无法直接把某个登录态“踢下线”只能在应用层想办法。其次是Payload可读JWT的中间段只是Base64URL编码不是加密任何人拿到token都能解码看到里面的内容。还有传输开销问题token每次请求都要放在Header里如果塞太多业务数据每个请求的带宽消耗都会变大。我最后决定用JWT驱动因素很明确多客户端共用一套接口、登录态需要在不同端之间保持一致、不想为Session专门维护一套Redis集群。如果你的系统是纯服务端渲染、单浏览器环境、且需要严格的会话级控制那继续用Session完全没问题没必要为了技术潮流强行切换。选型这件事一定要基于自己的场景做推演。2. 工程准备与依赖选型不只是引入两个包那么简单确定用JWT之后我一开始以为就是加两个Maven依赖的事结果发现版本差异、API变化、Spring Boot版本兼容这些问题都藏在细节里。这里单独列一节把工程准备的关键点说透。2.1 基础依赖与Spring Boot版本匹配我当时用的Spring Boot是2.7.xJava 11。如果你现在用的是Spring Boot 3.x要注意一个非常大的变化原来的javax.servlet命名空间变成了jakarta.servlet导致WebMvcConfigurer、HandlerInterceptor这些类的导入路径全变了网上很多老教程直接抄会编译失败。Maven依赖建议这样加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency 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这里解释一下为什么jjwt要拆三个依赖。0.11.x版本开始jjwt-api是编译期需要的接口和注解jjwt-impl是运行期实现jjwt-jackson负责把JSON和JWT的Claims之间做序列化转换。后两个声明为runtime即可因为代码里只需要引用api包的类型。网上很多教程还在用0.9.1的旧API比如setSubject、setExpiration、setSigningKey这些方法在0.11之后都被替换成了builder()风格或parserBuilder()风格照抄老代码一定会踩编译错误。2.2 一个值得讨论的问题要不要引入Spring Security这是很多初学者最容易纠结的地方。我的项目做了一个很务实的决定不引入Spring Security用拦截器加JWT工具类完成认证。原因是项目的权限模型很简单登录后接口只需要知道“当前用户是谁”角色判断没有复杂到需要Security那套过滤器链。Spring Security的优点是强大且规范但也意味着要理解SecurityFilterChain、AuthenticationManager、UserDetailsService、UserDetails等一系列概念配置成本和学习成本都不低。引入之后如果配置不当可能连静态资源和登录接口都被拦得死死的排查起来很痛苦。如果项目已经用了Spring Security那JWT也可以作为认证机制接入思路是写一个JwtAuthenticationFilter注册进过滤器链在OncePerRequestFilter里解析token并设置SecurityContext。关键点是不要同时使用拦截器和Security两套鉴权逻辑否则会出现请求被反复校验、白名单规则互相打架的问题。选一套就好。2.3 jjwt与auth0 java-jwt的选型对比除了jjwt另一个常用库是auth0的java-jwt。两者各有拥趸我简单做了个对比对比项jjwtjava-jwtauth0API风格Builder流式风格灵活但版本间变化较大直接构造API稳定上手快模块划分0.11后拆为api/impl/jackson三部分单依赖引入简单异常类型ExpiredJwtException等分类细致TokenExpiredException、SignatureVerificationException等文档与案例社区案例多但老版本教程混杂官方文档清晰适合场景需要深度定制、未来可能要切换非对称算法简单读写token、希望快速跑通我最终选了jjwt主要是团队对它的流式API更熟悉而且后续做RS256切换时jjwt的signWith和parserBuilder切换签名密钥比较直观。如果你只是想在SpringBoot里快速跑通一个登录认证auth0那个库也可以没必要在这个选择上花太多时间。3. Token的产生Header、Payload与签名背后的细节JWT由三段组成Header是算法和令牌类型Payload是声明数据Signature是对前两部分的签名。很多人写完generateToken就算完事但我在实际开发中深刻体会到Claims设计和签名算法选择才是真正影响安全性和后期维护成本的地方。3.1 Claims设计哪些信息该放哪些坚决不能放Claims就是Payload里那些键值对。标准字段有三个我基本必用sub放用户IDiat放签发时间exp放过期时间。自定义字段根据业务需要添加但我已经形成了一套明确的放与不放的清单建议放入原因sub用户ID唯一标识服务端凭它定位用户username登录账号非敏感信息方便日志排查clientType区分Web/App/小程序端方便做设备管理jtitoken唯一ID做注销黑名单时必用version用户版本号用于改密/权限变更后批量失效旧token禁止放入原因密码/密码哈希绝对红线Payload只是编码不是加密手机号/身份证号个人敏感信息token一旦泄露直接裸奔支付信息/卡号涉及资金安全绝不能进token大段业务对象会让token臃肿每个请求都多带几KB我之前看过一个项目为了少查一次数据库把用户手机号、性别都塞进了token后来token在日志里泄露用户隐私数据全被看到了。这就是没理解Base64URL编码的本质它只是让内容在网络传输中不出现特殊字符并没有任何保密能力。签名只保证内容未被篡改不保证内容不可见。所以结论很简单只放能用于识别用户的必要信息其他一律不放。3.2 HS256还是RS256签名算法选择的实际考量签名算法直接影响密钥管理和安全性。HS256是对称签名签发和验证用同一个密钥RS256是非对称签名私钥签发公钥验证。我最终在项目里用了HS256理由是这个系统所有服务端都在自己的控制范围内对称密钥只要做好保密就够用。如果以后有多个微服务都需要验证token或者要把验签能力开放给第三方我会再切换到RS256因为那时只需要把公钥分发出去私钥依然牢牢掌握在自己手里泄露面小很多。HS256的一个硬性要求是密钥至少256位也就是32字节。之前我用了一个很短的口令当密钥启动服务不报错一到签发token就抛WeakKeyException这属于最常见的入门坑。密钥的生成方式可以用openssl rand -base64 48然后从环境变量或配置中心读取绝不能硬编码在代码里。另外密钥不要每次重启都重新随机生成否则token在服务重启后立刻全部失效用户被迫重新登录体验很差也容易让运维同学误以为系统出了故障。3.3 核心代码实现JwtUtil工具类工具类我建议做成一个无状态的final类只提供静态方法不注入Spring的Bean。因为工具类不持有业务状态静态方法足够而且用起来不受Spring容器启动时机限制。以下是我实际使用的简化版本public final class JwtUtil { private static final String SECRET_KEY 换成你的环境变量注入的32字节以上随机字符串; private static final SecretKey KEY Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); private static final long ACCESS_EXPIRE_MS 2 * 60 * 60 * 1000L; // 2小时 private JwtUtil() {} public static String generateAccessToken(Long userId, String username, String clientType) { Date now new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(clientType, clientType) .setIssuedAt(now) .setExpiration(new Date(now.getTime() ACCESS_EXPIRE_MS)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } public static Long getUserId(String token) { return Long.valueOf(parseToken(token).getSubject()); } }这里有两个极易被忽略的细节。第一parseClaimsJws和parseClaimsJwt是两个不同的方法前者会验证签名后者只解析不验签。如果误用了后者等于把签名校验彻底绕过了攻击者自己改完Payload也能通过。第二signWith方法如果直接传字符串密钥在0.11版本里已经被废弃必须用Keys.hmacShaKeyFor得到SecretKey对象再传进去否则会有运行时异常或警告。3.4 过期时间与时钟偏移问题过期时间设置要根据业务容忍度来定。管理后台这种敏感系统AccessToken我倾向于设置30分钟到2小时面向普通用户的App可以适当放宽到24小时以内但不管怎么设置都要配合一个更长效的RefreshToken做无感续期这块下一章展开说。容易踩的坑是服务器时钟不一致。JWT里的exp和iat都基于时间戳如果签发方和校验方的系统时间差得比较多就会出现一种诡异现象同一个token在A服务正常在B服务却提示已过期。分布式环境下各节点时间不同步是常态所以在解析时可以允许一定秒数的时钟偏移jjwt的parserBuilder支持这个配置我一般会设置30秒左右。这样能有效避免线上偶发的401误判。4. 认证拦截链路从Filter到Interceptor的实现取舍Token生成好了接下来是核心环节每个请求进来时如何把token解析出来、校验、放行或拒绝。这一步Go实现方式选择直接影响后续的开发体验我把两种方案的取舍写清楚。4.1 认证拦截器基于HandlerInterceptor的落地方式我的项目最终用的是Spring MVC的HandlerInterceptor整个认证逻辑集中在preHandle一个方法里。基本流程是这样从请求头取Authorization期望值是Bearer xxx格式取不到就直接返回401拿到token后解析校验如果通过了把用户ID和用户名放进UserContext这个ThreadLocal容器然后放行如果token过期或非法设置好统一的错误响应返回false终止请求。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { writeUnauthorized(response); return false; } String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); UserContext.set(claims.getSubject(), claims.get(username, String.class)); return true; } catch (ExpiredJwtException e) { writeError(response, 401, 登录已过期请重新登录); return false; } catch (JwtException e) { writeError(response, 401, token无效或已被篡改); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }afterCompletion里清理ThreadLocal这一步非常关键。Tomcat的工作线程是复用的当前请求处理完如果不清理ThreadLocal下一个请求复用同一线程时就能读到上一个用户的登录信息这是非常典型的内存泄漏和越权漏洞。拦截器注册也简单在WebMvcConfigurer的addInterceptors里完成同时声明白名单Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/refresh, /error ); } }白名单要跟着业务走。登录接口肯定不能拦截刷新token的接口也要放行因为用户正是因为AccessToken过期才来刷新。健康检查接口、Swagger文档这些调试入口建议按需放行但放行不代表不记录日志审计还是要做的。4.2 Filter和Interceptor到底怎么选这个问题几乎每个整合JWT的开发者都会遇到。我总结一下自己的判断标准选Filter选Interceptor需要对所有请求做处理包括静态资源和Servlet层面的内容只关心Controller层的接口鉴权使用了Spring Security需要融入SecurityFilterChain需要拿到HandlerMethod方便做方法注解鉴权需要在非常前置的阶段做校验希望异常能被Spring MVC统一处理对于纯REST API项目Interceptor的代码量更少调试时在IDE里可以直接看到handler参数路径匹配addPathPatterns也很直观。如果你的项目已经引入了Spring Security那就走Filter路线注册一个OncePerRequestFilter解析token并写入SecurityContext不要两套同时用不然一个请求会被重复鉴权白名单规则也会变得很难维护。4.3 401响应体的统一约定拦截器里返回401时响应格式要和业务错误保持一致。我常用的结构是{ code: 401, message: 未登录或token已过期, data: null }前端axios拦截器只要判断HTTP状态码是401就统一跳转登录页。状态码层面直接用401而不是返回200然后塞一个业务错误码因为这样可以被网关、监控系统和前端封装统一识别排查问题也更直观。唯一要注意的是拦截器里别直接拼JSON字符串最好用项目的统一响应对象和序列化工具否则字段命名风格不一致前端又要多做一层适配。5. 还没完RefreshToken、注销与续期的完整闭环很多教程写到“生成了token、加个拦截器校验”就结束了但一个真正能上线的认证系统必须把续期、注销、失效这几个环节串起来。这一章是我在实际项目中补课最多的地方。5.1 为什么需要RefreshToken以及怎么设计AccessToken如果设得很短用户体验会很差每半小时就要重新登录。如果设得很长token泄露后的风险窗口又太大。解决办法是拆成两个tokenAccessToken时效短负责每次请求的鉴权RefreshToken时效长负责在AccessToken过期后静默续期。在我的项目里AccessToken有效期2小时RefreshToken有效期7天。客户端收到401后前端拦截器自动调用刷新接口带RefreshToken换新的AccessToken用户完全无感。RefreshToken虽然名义上“无状态”但要支持注销和防盗用还是得存一份服务端记录。我把RefreshToken的jti存进了Rediskey是refresh:{userId}:{clientType}value是jti过期时间设成7天。刷新时把用户传来的jti和Redis里存的比对一致才允许签发新AccessToken。这会多一次Redis查询但换来的能力是“我可以主动干掉某个设备的登录态”我觉得非常值。5.2 注销与黑名单无状态token的补丁方案JWT无状态带来一个天然缺陷签发之后服务端就无法主动让token失效。解决思路有两条。一条是黑名单方案。签发AccessToken时生成jti这个唯一ID用户注销时把jti写进Redis过期时间设置为该token的剩余有效期。每次鉴权时先查一下jti是否在黑名单里如果在就直接拒绝。这个方案覆盖场景全但代价是每个请求都要多一次Redis查询高并发下要想清楚能不能接受。另一条是用户版本号方案。在用户表或Redis里维护一个version字段签发token时把这个版本号放进Claims。每次修改密码、踢人下线、权限变更时给对应用户的版本号加一。校验token时对比Claims里的version和当前的version不一致就拒绝。这个方案不用每个请求都查Redis只需要在关键操作后递增版本号实现更轻量。我最终采用的是组合方案AccessToken短时效控制基本风险RefreshToken服务端存储支持主动失效用户改密时版本号全局递增确保历史token失效。注销的时候再叠一个jti黑名单覆盖“改密前最后一秒钟签发的token”这类极端窗口。如果你的项目对注销时效要求没那么苛刻先做RefreshToken失效和版本号两个机制也能覆盖绝大多数场景。5.3 客户端怎么存tokenHttpOnly Cookie还是localStoragetoken存储位置直接关系到XSS和CSRF的风险平衡。很多前端习惯把token放localStorage写起来简单但一旦页面被注入脚本localStorage里的token可以被直接读走。更稳妥的做法是AccessToken放前端内存变量页面刷新时让刷新接口重新恢复RefreshToken放在HttpOnly Cookie里JavaScript读不到XSS攻击者拿不到同时给Cookie设置SameSiteLax来缓解CSRF问题。App端则把RefreshToken放进系统安全存储区比如Keychain或Keystore。这个方案初期开发确实要多处理一次“页面刷新后重新换取AccessToken”的逻辑但安全收益明显值得投入。6. 线上踩坑记录与自查清单方案跑通的当天我以为事情就结束了结果后续线上问题又给我上了一课。这里挑几个真实遇到的坑记录一下每一条都是网上教程很少提的细节。6.1 WeakKeyException密钥长度不够第一次签发token就报错日志清晰指向密钥强度不足。HS256要求密钥至少256位我当时用了my-secret-key这种短字符串长度不够。网上很多demo代码都是用这样的短key因为它们只在本地跑一旦要到生产环境就原形毕露。修复方法是使用足够长的随机字符串比如openssl rand -base64 48的结果用环境变量注入。另外密钥一定不能提交到Git仓库要么从配置中心读取要么在部署平台的环境变量里配置不同环境用不同值。6.2 解析异常千万别一股脑返回“登录过期”JwtException是一个宽泛的父类底下有ExpiredJwtException过期、UnsupportedJwtException算法不支持、MalformedJwtException格式损坏、SignatureException签名不匹配等。如果所有异常都返回同一个错误码前端和后端排查问题会非常痛苦。签名不匹配大概率意味着token被篡改属于安全事件格式损坏可能只是客户端传输时做了URL编码没还原登录过期才是正常业务状态。这三种必须分开处理我给每种异常都给了不同的错误提示并打了独立的日志标记方便安全监控。6.3 跨域预检请求把拦截器卡死了前端请求带Authorization头时会触发CORS预检浏览器先发一个OPTIONS请求不带任何业务头。如果拦截器不处理这个OPTIONS请求就会因为没有token被直接拦掉浏览器控制台报CORS错误而后端日志里只有一条OPTIONS请求被401的痕迹定位起来非常迷惑。解决办法是两层都要做拦截器里对OPTIONS请求直接放行同时正确配置CorsFilter在允许的请求头里加上Authorization。如果项目里还有跨域过滤器执行顺序也要注意CORS处理尽量放在Servlet Filter层面这样预检请求在进入拦截器之前就完成了。6.4 日志里不要打印完整token有一次排查接口问题时我临时在拦截器里把token打到了日志里后来忘了删。这个完整token如果从日志平台被拖走攻击者可以直接拿它冒充用户访问接口。从那以后我的规范是所有日志只记录用户ID和token前10位绝不输出完整token尤其是包含签名的第三段。这个习惯在开发阶段养成比出事后再清理容易得多。6.5 开发环境和生产环境的过期时间分开配置本地调试时2小时的AccessToken有效期其实够用但如果频繁改代码重启服务Redis里的RefreshToken和本地密钥不一致还是会被迫重新登录。为了方便我把过期时间做成了配置项jwt.access-token.expire-seconds开发环境设置为7天生产环境设置为7200秒。上线前一定要把生产值改回来否则token泄露后的风险窗口会拉长到不可接受。6.6 上线前的自查清单贴一份我自己整理的上线检查清单每次新项目接入JWT认证时都会过一遍[ ] 签名密钥长度是否大于等于32字节来自配置中心或环境变量未存储在代码仓库[ ] HMAC密钥是否做到不同环境隔离生产与开发不共用[ ] Claims中是否包含密码、手机号、身份证号等敏感数据[ ] 拦截器是否对OPTIONS预检请求放行[ ] token过期异常与其他JwtException是否已分开处理[ ] ThreadLocal中的用户信息是否在afterCompletion中完成清理[ ] 日志系统是否已确认不会打印完整token[ ] RefreshToken是否实现轮换注销后是否能让AccessToken尽快失效[ ] 解析token时是否设置了合理的时钟偏移量[ ] 生产环境AccessToken过期时间是否已从调试值切回短时效值最后按老规矩收个尾吧。整个方案跑下来我的体会是JWT只是一个把用户身份以自包含、可签名、可验签的方式在客户端和服务端之间传递的工具选择它不等于选择了一套“永远正确”的认证方案。真正让登录系统好用的是AccessToken短时效、RefreshToken轮换、注销黑名单这些容易被忽略的闭环设计。还有一个我后来一直保留的小技巧本地调试时把jwt.access-token.expire-seconds临时调长能避免频繁重启后登录态丢失的烦躁感但上线之前记得改回来再配好RefreshToken续期机制。希望这篇SpringBoot整合JWT的实战记录能帮你少走几步弯路。如果你在项目里也遇到过一些奇怪的认证问题欢迎一起交流讨论很多时候所谓疑难杂症最后发现都是某个小细节没接对。