
1. 升级前必须想明白的一件事Session 替换成 JWT到底换掉了什么我们团队上个月刚把一套沿用了好几年的 Session 登录体系升级成 JWT本来以为就是把查 Redis 会话换成验 token 签名这么简单结果上线第一周就连续处理了三个线上问题有人发现服务端改了密码之后旧 token 居然还能继续访问有人反馈移动端登录态总是不明不白丢失安全评审还直接指出密钥放在配置文件里属于重大隐患。这三件事本质上指向的都是同一个方向——JWT 不是另一种 sessionId它把原本由服务端掌握的状态控制权整个转移到了客户端手里由此带来的两个核心问题就是密钥与签名安全、以及无状态 token 的过期续签与主动失效。这篇文章不打算从 JWT 的 RFC 文档讲起而是围绕这次升级中我实际遇到的两个关键问题展开怎么用正确的算法和密钥管理方式保证 JWT 不被伪造怎么在 token 过期、用户改密、账号封禁这些真实场景下让无状态认证变得可控。顺带会把 Spring Boot 3 / Spring Security 6 下整合 JWT 的完整配置过程写出来因为这一代 Spring Security 的配置方式和老版本差别太大网上很多教程还停留在WebSecurityConfigurerAdapter时代直接照抄是跑不起来的。先说背景。传统 Session 方案的流程是用户登录成功后服务端生成一个随机 sessionId把会话数据存进服务端内存或者 Redis客户端拿到 sessionId 后放在 Cookie 里下次请求时服务端拿 sessionId 去查会话状态查到就放行查不到就 401。这个方案最大的痛点是有状态——用户量一大你得上 Redis 做会话共享微服务拆分了每个服务都要去同一个会话存储里查状态做移动端或者前后端分离项目时Cookie 的跨域、CSRF 问题又会冒出来。JWT 的玩法完全不同。登录成功后服务端签发一段自包含的 token里面直接携带用户 ID、角色、过期时间等声明关键是用签名保证内容没被篡改。之后的请求只要带上 token服务端验签通过就直接信任不需要去任何地方查状态。无状态带来的好处是显而易见的服务随便水平扩展实例之间不需要任何会话共享SPA、小程序、App 都能轻松使用鉴权逻辑被压缩成一次签名校验性能也比查库好。但代价同样明显服务端失去了主动回收会话的能力。Session 方案里你可以随时删掉 Redis 里的会话来踢人下线JWT 方案里没有这个开关一个合法 token 在过期之前服务端很难阻止它继续使用。这就是无状态这枚硬币的另一面。所以在动手写代码之前先做个技术盘点是非常重要的。我会把认证成功后怎么发 tokentoken 过期了怎么续签改密码之后旧 token 怎么失效密钥怎么保护这四个问题全部列清楚再决定整体方案而不是急着写过滤器。2. 第一个关键问题JWT 签名算法与密钥管理决定了 token 会不会被人随手伪造2.1 很多团队对 JWT 的误解它默认不是加密是签名我在排查安全评审问题时发现不少开发同学对 JWT 的第一反应是我把用户信息加密放进 token 里别人解不开所以安全。这个理解半对半错。JWT 的核心机制是签名而不是加密Signature 那段是拿密钥对 Header Payload 做 HMAC 或 RSA 运算得到的摘要。服务端验签通过只代表内容没被篡改和签发方可信不代表别人解不开。Payload 里的用户信息是 Base64URL 编码的任何人都可以解码看到内容只是改了之后验签会失败而已。所以 JWT 的安全性本质上完全押在签名算法和密钥上。如果算法选型有问题或者密钥保护不当等于把整个认证体系的大门敞开。2.2 常见的几类 JWT 漏洞都是真实发生过的第一类使用 HS256 对称算法并且把密钥硬编码在代码或配置文件里。HS256 的含义是双方共享同一个密钥签发用这个 key验签也用这个 key。问题是知道密钥的人不仅可以验证 token还可以伪造任意用户身份的 token。一旦密钥提交到 Git 仓库、泄露到日志、或者被前端的打包产物捞出来攻击者就能直接给自己签发一个管理员身份。很多安全新闻里提到的默认凭证问题本质都是这个——系统上线后没人换掉默认密钥攻击者拿公开的默认密钥伪造出一堆高权限 token。第二类算法混淆攻击。JWT 的 Header 里有alg字段标准的实现会用它决定验签算法。经典攻击是原本服务端用 RS256公钥验签、私钥签名攻击者把 token 的alg改成 HS256然后拿着服务端的公钥当 HMAC 的密钥来签名。由于部分旧版 JWT 库不会校验当前算法与配置算法是否一致服务端用公钥去验 HMAC 签名时居然能验过。这类攻击在 2015 年就被系统性地总结过到现在依然偶发在生产环境核心原因就是框架代码太老或者配置没有锁定算法白名单。第三类alg: none。更直白的漏洞——如果库支持none算法攻击者把 Header 改成{alg:none}去掉签名部分服务端也可能直接放行。虽然主流 JWT 库默认禁止 none但如果你用的是定制化实现或者某些老旧中间件这个坑依然存在。我的建议很明确能不用 HS256 就别用 HS256至少在跨服务场景下不要用。JWT 是给多个服务共享验签的如果所有服务都持有同一个对称密钥任何一个服务被攻破整个系统的认证密钥就全泄露了。换成 RS256 之后私钥只放在签发 token 的认证中心其他业务服务只拿公钥做验签风险面一下子就缩小了。2.3 落地方案RS256 非对称签名 密钥托管 kid 轮换我在这次升级里采用了一套比较稳妥的组合方案签名算法RS256RSA 2048 位私钥只在认证服务里保存公钥下发给所有需要验签的服务。密钥存放私钥放到环境变量或配置中心绝不放 Git 仓库密钥文件本身限制权限服务通过环境变量读取。密钥轮换用kidKey ID字段标记当前使用的密钥版本签发 token 时把kid写进 Header验签时根据kid选择对应的公钥。这样密钥轮换时老 token 依然可以通过旧公钥验签新 token 使用新密钥不需要强制所有用户重新登录。轮换时我会在配置中心保留一个公钥列表里面同时存在新旧两把公钥。旧公钥在它签发的最后一个 token 过期后就可以移除。这个做法很多大厂的内部网关也在用实际维护成本很低但能避免一刀切切掉所有在线用户的尴尬。2.4 JwtService 的代码实现要点下面这个JwtService是我项目里简化后的版本核心就三个方法生成 token、解析 token、校验 token。我使用的是jjwt库的 0.11.5 版本如果你用的是 0.9.x 老版本API 是完全不同的。Service public class JwtService { Value(${app.jwt.private-key}) private String privateKeyPem; Value(${app.jwt.public-key}) private String publicKeyPem; private PublicKey publicKey; private PrivateKey privateKey; PostConstruct public void init() { // 实际项目中建议用配置中心统一管理密钥这里用 PEM 文件内容解码 byte[] privateKeyBytes Base64.getDecoder().decode(privateKeyPem); byte[] publicKeyBytes Base64.getDecoder().decode(publicKeyPem); PKCS8EncodedKeySpec privSpec new PKCS8EncodedKeySpec(privateKeyBytes); X509EncodedKeySpec pubSpec new X509EncodedKeySpec(publicKeyBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); this.privateKey keyFactory.generatePrivate(privSpec); this.publicKey keyFactory.generatePublic(pubSpec); } public String generateToken(Long userId, String username, SetString roles, Duration ttl) { Date now new Date(); JwtBuilder builder Jwts.builder() .setSubject(username) .claim(uid, userId) .claim(roles, roles) .setIssuedAt(now) .setExpiration(new Date(now.getTime() ttl.toMillis())) .signWith(privateKey, SignatureAlgorithm.RS256); // 这里把当前密钥版本号放到 Header验签时根据 kid 选择公钥 return builder.setHeaderParam(kid, 2026-01-v1).compact(); } public Claims parseToken(String token) { try { JwtParser parser Jwts.parserBuilder() .setSigningKey(publicKey) .build(); return parser.parseClaimsJws(token).getBody(); } catch (ExpiredJwtException e) { throw new TokenExpiredException(token已过期); } catch (JwtException e) { throw new InvalidTokenException(token签名不合法); } } }校验逻辑里有一点要强调只处理ExpiredJwtException其他所有JwtException一律按非法 token 处理统一抛出 401不给出过多细节。有些安全规范要求错误信息不能区分签名错误和token 格式错误因为这会帮攻击者缩小猜测范围。3. 第二个关键问题无状态 token 的过期、续签与主动失效怎么在真实产品里兜底3.1 为什么过期时间怎么调都不对劲无状态认证上线后第一个被用户吐槽的就是为什么我第二天打开 App 又要重新登录。把 access token 的过期时间调长吧又有人担心 token 丢了被别人捡到后可以长期使用。这是一个典型的安全性和体验的拉锯战。我见过不少团队用一拍脑袋的方式定过期时间登录接口里写上setExpiration(new Date(System.currentTimeMillis() 3600_000))就算完事。但对于一个真实产品来说token 生命周期需要结合业务场景来设计。比如Web 后台管理系统操作频繁、用户粘性高access token 30 分钟内过期是合理的配合 refresh token 自动续期用户几乎感知不到。移动端 App网络切换频繁、用户可能长期不打开access token 可以放宽到 2 小时refresh token 保持 30 天。涉及支付、账号设置等敏感操作的接口不能只看 token 是否有效还要做步态检测、二次密码验证等额外校验。3.2 解决方案双 token 机制我最终采用的方案是双 token 机制access token负责短时间内的接口访问refresh token负责在 access token 过期后换取新的 access token。access token 生命周期短我设置为 30 分钟即使泄露攻击者可用窗口有限。refresh token 生命周期长7 天到 30 天存储在服务端的 Redis 里客户端发起刷新请求时需要携带它。刷新接口返回新的 access token 和新的 refresh token同时旧的 refresh token 立即作废。这叫refresh token 旋转能有效防止刷新凭证被反复重放。用 Redis 存 refresh token 是很多团队会忽略的细节。既然 refresh token 需要服务端状态支持那 JWT 的无状态优势本来就不是绝对的合理做法是让 access token 保持无状态refresh token 退回到有状态管理两边的好处都能吃到。3.3 refresh token 旋转中的并发问题这块我在上线后专门排查过一次。场景是前端同时发起多个请求access token 过期导致多个请求都走到刷新逻辑结果每个请求都拿着同一个 refresh token 去换新服务端旋转了一次之后其他并发的刷新请求发现旧 token 已经失效全部返回 401用户看到的就是请求集体失败。解决方案有两个角度第一是前端做刷新请求去重用一个refreshPromise变量缓存正在进行的刷新请求让多个并发请求复用同一个 promise避免重复发起刷新。第二是服务端在旋转 refresh token 时允许一个宽限期如果某个请求带着旧 refresh token 来刷新Redis 里已经找不到它那就去查一个专门的已作废 refresh token记录如果发现该 token 在最近 30 秒内刚被使用过视为正常并发情况返回同一个新的 token 给客户端如果距离上次使用时间很远才判定为重放攻击注销整个用户的所有登录态。这个处理看起来只有几行逻辑但并发场景下非常管用。不少开源项目里都有refreshTokenRotation相关的实现核心就是宽限期 一次性 token的组合拳。3.4 主动失效的兜底方案黑名单与用户版本号JWT 无状态导致改密码后旧 token 依然有效的问题我也在产品里遇到了。用户改密后告诉他其他设备已退出结果用旧的 access token 还能调用接口这就很难解释。纯粹的 JWT 方案做不到像 Session 那样精确删除但可以退一步在 Redis 里维护一个token_denylist存入需要提前失效的 token 的jtiJWT ID设置过期时间等于该 token 的剩余有效期。每次请求验签成功后再查一次黑名单命中就拒绝。更轻量的做法是给用户表加一个token_version字段用户改密码、封禁、被踢时让版本号自增签发 access token 时把版本号写进自定义 claim每次校验时对比数据库中的版本号不一致就拒绝。token_version方案的最大好处是 O(1) 判断、几乎不用额外存储缺点是每个请求都要查一次库或缓存。我这里直接用 Redis 缓存了用户 token 版本号在校验过滤器里多一次查询性能影响可以忽略但踢人下线这个功能终于算真正落地了。3.5 敏感操作强制二次验证最后即便是 JWT 带着合法签名也不代表当前操作者一定是本人。账号密码泄露、浏览器插件恶意调用接口的场景里token 就是合法的。所以我们在转账、修改手机号、查询详细收货地址这类高危接口上一律要求用户再次输入密码或短信验证码拿到一个有效期为 5 分钟的operation_token才能继续。这个设计本质上是用inherit 权限分离的思路来降低单点泄露风险和 JWT 本身并不冲突。4. Spring Security 整合 JWT 的完整落地配置Spring Boot 3 里的写法4.1 从 WebSecurityConfigurerAdapter 到 SecurityFilterChain如果你搜过 Spring Security 3 的配置代码会发现网上大量教程还在用extends WebSecurityConfigurerAdapter。Spring Boot 3 对应 Spring Security 6这个抽象类已经被移除了现在推荐的做法是声明一个SecurityFilterChain的 Bean。一个比较大的变化是过去在configure(HttpSecurity http)里http.csrf().disable()的链式调用现在换成了http.csrf(AbstractHttpConfigurer::disable)这类 Lambda 写法。还有几个默认值也变了默认启用 CSRF 保护、默认不再存储会话 Session 持久化等。如果你从旧版本直接迁移配置看起来会非常陌生这是正常的。4.2 自定义 JwtAuthenticationFilter 的接入位置JWT 校验依靠认证过滤器来完成。我实现了一个JwtAuthenticationFilter extends OncePerRequestFilter保证一个请求只会执行一次过滤逻辑。过滤器只做三件事从 Authorization 头解析 token、调用 JwtService 校验、把认证信息放进SecurityContextHolder。Component RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtService.parseToken(token); String username claims.getSubject(); // 这里顺便查一下 Redis 里的 token_version用于主动失效 if (tokenVersionService.checkVersion(username, claims)) { UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (TokenExpiredException | InvalidTokenException e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }过滤器的执行顺序在SecurityConfig里配置必须放在UsernamePasswordAuthenticationFilter之前也就是说让 JWT 认证优先于账号密码表单认证。Configuration EnableWebSecurity RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh, /api/captcha).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint((request, response, authException) - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, 请先登录)) .accessDeniedHandler((request, response, accessDeniedException) - response.sendError(HttpServletResponse.SC_FORBIDDEN, 没有权限)) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里有三个细节值得提醒SessionCreationPolicy.STATELESS是必须的它告诉 Spring Security 不要在服务端创建会话否则你一边用 JWT一边又往SecurityContext里塞 Session等于白折腾。csrf在 JWT 无状态模式下可以关闭因为 CSRF 攻击本质上依赖浏览器自动携带 CookieJWT 通常放在自定义 Header 里不存在这个前提。但如果你为了兼容特殊客户端把 token 放在 Cookie 里那 CSRF 保护绝不能关。authenticationEntryPoint和accessDeniedHandler要分别处理未认证和已认证但无权限这样前端才能根据 401 和 403 做不同的跳转提示。4.3 登录接口、验证码和 JWT 的组合做 SPA 项目时验证码登录是很常见的前置流程。登录接口我设计为先校验图形验证码或者短信验证码通过后校验账号密码最后签发 access token 和 refresh token。验证码存在 Redis 里设置 5 分钟过期验证一次就删除防止重放。刷新接口单独设计为拿着 refresh token 换取新 access tokenrefresh token 可以从请求体获取也可以从专门设置的 HttpOnly Cookie 获取。我建议用 HttpOnly Cookie 存 refresh token能在一定程度上防止 XSS 窃取因为 JavaScript 读取不到 Cookie 的值。PostMapping(/auth/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String oldRefreshToken request.getRefreshToken(); // 1. 解析并验签 refresh token // 2. 检查 Redis 中是否存在且未被使用过 // 3. 签发新的 access token // 4. 执行 refresh token 旋转旧 token 作废 }4.4 与 Spring 方法级权限的配合很多业务希望PreAuthorize(hasAuthority(order:create))这种方法级权限也能直接生效。JWT 用户信息里的roles需要映射成GrantedAuthority。我在生成 token 时会把角色列表放进 claims解析后逐条转换成SimpleGrantedAuthority。有一个不太容易被注意到的点如果角色是中文或者带特殊前缀比如ROLE_ADMIN一定要在签发和解析两侧保持完全一致的规则。我遇到过前端明明拿到了ADMIN后端的hasRole(ADMIN)却始终返回 403最后发现 token 里存的是ROLE_ADMIN而hasRole又自动加了一次ROLE_前缀变成了ROLE_ROLE_ADMIN。这个问题的排查过程就是典型的配置对不上。5. 上线后的踩坑记录与排查链路每一条都是真实处理过的5.1 服务器时钟偏移导致的token 提前过期上线第一天就有一条用户反馈刚登录完没过几分钟就被提示登录过期。排查链路是这样的先看代码逻辑setExpiration是 30 分钟不应该这么快过期再检查服务器时间发现生产环境有两台节点的时间差了将近 8 分钟——认证请求打到了 A 节点校验请求由 B 节点处理而 B 节点的时钟比真实时间快了 8 分钟所以一个还有 30 分钟寿命的 token 在 B 节点眼里只剩 22 分钟再叠加网络延迟和用户操作时间体验就变成了秒掉线。解决方案有两个方向一是统一所有节点的 NTP 时间同步这是治本二是在验签的JwtParser里设置setClockSkewSeconds(60)给时间校验留出 60 秒冗余。我在项目里两个都做了NTP 同步解决时间精度60 秒冗余处理偶发网络波动。5.2 Base64URL 编码与特殊字符的坑JWT 的 Header 和 Payload 用的是 Base64URL 编码而不是标准 Base64。标准 Base64 中可能出现、/、这三个字符如果直接放进 URL、Header 或者 JSON 字段里会被解析器误解。我在对接 App 端时遇到过 token 里出现号App 把整段 token 发给服务端后签名一直校验失败。最后发现是客户端在从 JSON 读取 token 时把解析成了空格导致 token 被悄悄篡改。解决办法是后端签发 token 时确保encoding使用Base64.getUrlEncoder().withoutPadding()前端拿到的 token 里就不会有、/、这些惹麻烦的字符。5.3 角色变更后权限不生效的问题用户被管理员降权之后如果 token 里的角色信息还是旧的那用户在新 token 过期之前依然能访问原来的权限接口。这个问题归根结底还是 JWT 无状态带来的。在这个场景下我直接查库校验权限显然违背了无状态设计初衷于是用了折中方案只把角色信息放进 access token 里并且用第 3.4 节提到的token_version机制管理员修改用户权限时让版本号自增旧 token 立即失效用户下次刷新或重登时拿到新角色。虽然这会让敏感权限修改变成强制重新登录但从安全角度看这是合理的代价。5.4 日志脱敏别把 token 打进日志排查接口问题时很多人习惯直接log.info(request header: authHeader)。这在 JWT 系统里是极其危险的操作——token 一旦进入日志权限就可能跟着泄露。我排查完问题后专门加了一个日志过滤器对 Authorization、refresh_token、operation_token 等敏感字段统一打码只保留前 10 个字符和最后 10 个字符。另外前端在接口报错时也做了处理不允许直接把异常响应里的 token 内容渲染到错误页面上。5.5 前端 token 存储选型最后说个前端工程的坑。很多 SPA 教程建议把 access token 存在 localStorage 里理由是够简单、Axios 拦截器容易取。但如果站点哪怕有一个 XSS 漏洞攻击者就能localStorage.getItem(token)把令牌偷走。我现在的做法是access token 放在内存里用集中式状态管理库刷新页面后通过 refresh token 换新refresh token 放在 HttpOnly Cookie 里JS 页面无法直接读取。这个方案牺牲了一点刷新页面即时恢复的无缝体验但安全性高了很多。如果你的项目对体验要求极高折中做法是 access token 存 sessionStorage至少比 localStorage 的泄露面小一点。我在整个升级过程中最大的体会是JWT 本身不复杂复杂的是把无状态认证放进一个有状态需求的产品里。密钥保护、token 生命周期、主动失效、多端并发这些才是真正需要花时间去设计的地方。如果你的项目正在做类似的改造我建议先按照第二章和第三章的问题清单做一轮自检再把第四章的代码骨架搭起来最后结合自己业务的并发和敏感接口情况做细节调整。上面提到的踩坑记录大部分在架构阶段就可以避免不必等到线上用户投诉之后再回头排查。