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 在过期之前,服务端很难阻止它继续使用。这就是"无状态"这枚硬币的另一面。
所以在动手写代码之前,先做个技术盘点是非常重要的。我会把"认证成功后怎么发 token""token 过期了怎么续签""改密码之后旧 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 轮换
我在这次升级里采用了一套比较稳妥的组合方案:
- 签名算法:RS256(RSA 2048 位),私钥只在认证服务里保存,公钥下发给所有需要验签的服务。
- 密钥存放:私钥放到环境变量或配置中心,绝不放 Git 仓库;密钥文件本身限制权限,服务通过环境变量读取。
- 密钥轮换:用
kid(Key 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, Set<String> 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 的jti(JWT 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 攻击本质上依赖浏览器自动携带 Cookie,JWT 通常放在自定义 Header 里,不存在这个前提。但如果你为了兼容特殊客户端把 token 放在 Cookie 里,那 CSRF 保护绝不能关。authenticationEntryPoint和accessDeniedHandler要分别处理"未认证"和"已认证但无权限",这样前端才能根据 401 和 403 做不同的跳转提示。
4.3 登录接口、验证码和 JWT 的组合
做 SPA 项目时,验证码登录是很常见的前置流程。登录接口我设计为:先校验图形验证码(或者短信验证码),通过后校验账号密码,最后签发 access token 和 refresh token。验证码存在 Redis 里,设置 5 分钟过期,验证一次就删除,防止重放。刷新接口单独设计为:拿着 refresh token 换取新 access token,refresh 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 生命周期、主动失效、多端并发,这些才是真正需要花时间去设计的地方。如果你的项目正在做类似的改造,我建议先按照第二章和第三章的问题清单做一轮自检,再把第四章的代码骨架搭起来,最后结合自己业务的并发和敏感接口情况做细节调整。上面提到的踩坑记录,大部分在架构阶段就可以避免,不必等到线上用户投诉之后再回头排查。