
前阵子接手一个老项目的登录模块改造技术栈定的是Spring Security JWT。说实话这两样东西单拎出来都不算难但把它们真正组合起来部署到生产环境中间隔着大量文档里不会写的细节。这套组合我前后在不同项目里用过三四回每次都还是能踩出新坑所以干脆把原理、落地代码、漏洞防御和续签方案一次性整理出来。这篇文章适合正在做无状态认证改造的Java后端同学也适合那些已经接上JWT但总担心哪里埋了雷的团队。1. 为什么Spring Security和JWT会成为认证首选从Session到无状态1.1 传统Session方案在分布式环境下的尴尬很早以前做Web登录大家习惯用Session Cookie。流程很简单用户提交账号密码服务端校验通过后往Session里存一份用户信息同时下发一个JSESSIONID写到浏览器Cookie后续请求带着这个ID服务端就能认出你是谁。单机部署没有任何问题。但到了多实例部署时代这个方案就开始难受了。请求被负载均衡打到不同节点Session落在A机器下一次却被转发到B机器B机器查不到Session用户就被迫重新登录。当时社区里主要有三种补救方案Session黏滞让同一个用户的请求始终打到同一台机器上。简单但不优雅节点重启时Session全丢。Session复制多实例之间广播同步Session数据。集群规模一大广播风暴能把网络打垮。Redis集中存储把Session挪到Redis里多实例共享。这是当时的主流解法但代价是每次请求都要做一次序列化和反序列化还得额外维护一套Redis的高可用。更麻烦的是跨域和移动端场景。Cookie在跨域请求里天然受限SameSite策略一收紧前端连登录态都带不上来。移动端App压根没有Cookie的概念还得去手动拼请求头。这些痛点累积到一定程度就逼着大家寻找一种完全不需要“服务端状态”的认证方式。1.2 JWT的三段结构为什么它天然适配无状态JWTJSON Web Token就是一种典型的无状态凭据。它本身就是一个加密签名的JSON字符串分成三段用点号分隔。Header里记录签名算法通常是HS256或RS256。Payload里放核心声明数据比如sub用户名、iat签发时间、exp过期时间也可以塞自定义字段比如用户角色、租户ID。最后的Signature是对前两段做签名后的结果保证内容不可被篡改。一个简化后的JWT长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwMDAyMDAwfQ.dQxY0uYwV2mUVgVbWnH1E7HmE5P3T0WmXrZk2oXVJkQ服务端拿到这个Token用本地密钥验签签名合法再检查exp没过期就直接信任Payload里声明的用户身份完全不需要去数据库或缓存里查任何会话记录。这就解决了Session方案里最头疼的分布式状态共享问题——每个节点都能独立完成验签水平扩容变得极其轻松。需要注意一点Payload只是Base64URL编码并不是加密。用工具一解码就能看到里面的明文内容所以千万别把密码、手机号这类敏感数据直接塞进JWT。这条我在下文的漏洞部分还会强调。1.3 Spring Security的过滤器链模型给JWT留好了插槽很多初学者被Spring Security的配置吓到其实它的核心模型很朴素一条过滤器链。一次HTTP请求进来会依次经过一系列过滤器每个过滤器决定放行、拦截或做一些前置处理。Spring Boot内置的默认链路里UsernamePasswordAuthenticationFilter负责处理用户名密码登录FilterSecurityInterceptor负责做最后的授权判断。我们接JWT时做的事情本质就是往这条链路的合适位置插入一个自定义过滤器——JwtAuthenticationFilter让它先于认证逻辑执行从请求头提取Token验签然后把认证结果塞进SecurityContextHolder。这个模型的好处是Spring Security原有的方法级权限控制PreAuthorize、URL级权限控制authorizeHttpRequests全部照常工作我们只是在它们前面多了一步“用Token解析出用户身份”。所以Spring Security和JWT不是谁替代谁的关系而是过滤器链上的自然协作。2. 一次登录请求背后的完整链路从AuthenticationManager到SecurityContextHolder2.1 登录接口简单的三行代码背后是一整套认证流程先看最直观的登录接口代码PostMapping(/api/auth/login) public ResponseEntity? login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String accessToken tokenProvider.createAccessToken(authentication); String refreshToken tokenProvider.createRefreshToken(authentication); return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken)); }很多人疑惑authenticationManager.authenticate()这一行背后发生了什么。实际上AuthenticationManager会遍历所有注册的AuthenticationProvider找到能处理UsernamePasswordAuthenticationToken的那个Provider。Provider内部会调用UserDetailsService去数据库加载用户记录再通过PasswordEncoder比对密码哈希。密码校验通过后返回一个已认证的Authentication对象里面包含了完整的GrantedAuthority权限列表。我们就是从这个对象里取出用户名和角色塞进JWT的Payload。有一个配置上的坑必须提醒默认情况下Spring Security不会自动在容器里注册AuthenticationManager的Bean需要手动暴露Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }如果忘了这个启动时接口里注入AuthenticationManager会直接报没有可用Bean的错误排查起来还得翻一会儿源码。2.2 JwtAuthenticationFilter所有业务接口的守门员登录接口产出了Token后续每一个请求都需要经过JwtAuthenticationFilter来验证Token。这个过滤器通常继承OncePerRequestFilter确保一次请求只执行一次逻辑Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token)) { Claims claims tokenProvider.parseToken(token); String username claims.getSubject(); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }两个细节值得展开说。第一个是resolveToken的判断逻辑。按照惯例Token放在Authorization请求头里带Bearer前缀。代码里用substring(7)跳过前缀这里的7就是Bearer 的字符长度。如果你从Cookie里取Token逻辑就不一样了这点根据项目具体场景来定。第二个是loadUserByUsername。这里有个性能与实时性的权衡。每次请求都查一次数据库高并发下压力很大但如果完全不查库用户被禁用或者密码被修改后已签发的Token仍然有效。我的做法是引入Spring Cache给用户信息加一个5分钟级别的缓存这样既不会每个请求都穿透到数据库又能在最多5分钟内感知到用户状态变化。对多数业务系统来说这个延迟完全可接受。2.3 SecurityContextHolder一次请求线程内的一次性上下文过滤器里做了SecurityContextHolder.getContext().setAuthentication(...)之后业务代码怎么拿当前登录用户标准写法是Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName(); ListString roles authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList();SecurityContextHolder底层用的是ThreadLocal意味着认证信息绑定在当前处理请求的线程上。一次请求从进入到返回这条线程拿到的都是同一个上下文。这也是Spring Security这么多年设计中非常顺手的一个点——业务代码不用把用户对象作为参数层层传递随时可以从静态方法里取。但ThreadLocal有个著名的副作用异步线程拿不到父线程的上下文。如果你在业务代码里用Async开了子线程子线程里SecurityContextHolder.getContext().getAuthentication()会返回null。解决方式是在提交异步任务前先取出Authentication对象作为参数显式传给子线程或者配置Spring Security官方提供的DelegatingSecurityContextExecutor装饰器。这个坑在日志系统里特别常见——异步日志需要记录操作人结果拿不到用户名。3. 落地代码JwtTokenProvider、SecurityConfig与异常处理三件套3.1 JwtTokenProviderToken的生成、解析、校验全在这一处为了避免各处散落JWT相关代码建议把逻辑全部收敛到一个工具类里。我用的是jjwt 0.12版本这个版本的API相比老的0.9有比较大的调整代码里体现的是新版写法Component public class JwtTokenProvider { private final SecretKey key; private final long accessTokenValidityMs; private final long refreshTokenValidityMs; public JwtTokenProvider(Value(${jwt.secret}) String secret, Value(${jwt.access-token-validity-seconds}) long accessTokenValiditySeconds, Value(${jwt.refresh-token-validity-seconds}) long refreshTokenValiditySeconds) { byte[] keyBytes Decoders.BASE64.decode(secret); this.key Keys.hmacShaKeyFor(keyBytes); this.accessTokenValidityMs accessTokenValiditySeconds * 1000; this.refreshTokenValidityMs refreshTokenValiditySeconds * 1000; } public String createAccessToken(Authentication authentication) { String username authentication.getName(); Instant now Instant.now(); return Jwts.builder() .subject(username) .claim(roles, authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(accessTokenValidityMs))) .signWith(key, Jwts.SIG.HS256) .compact(); } public String createRefreshToken(Authentication authentication) { String username authentication.getName(); Instant now Instant.now(); return Jwts.builder() .subject(username) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusMillis(refreshTokenValidityMs))) .signWith(key, Jwts.SIG.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }代码里有几个点要单独拎出来讲。密钥的生成和编码方式是很多人忽略的地方。HS256算法要求密钥长度至少256位也就是32字节。Decoders.BASE64.decode(secret)表示配置里的jwt.secret是一段Base64编码的密钥Spring会在启动时解码成SecretKey对象。如果你直接配置一个简单的字符串比如my-secret长度远不够启动就会报弱密钥异常。claims里面放了roles这样授权判断时可以不用每次都查库拼权限。但代价是角色变更不会立刻生效和上文提到的用户状态校验一样是性能和实时性之间的权衡。我的习惯是角色放Token里权限变更的容忍度控制在Token过期时间以内。3.2 SecurityConfig放行规则、无状态会话与过滤器插入位置过滤器有了Token处理器有了接下来把它们接进Spring Security的链路Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/captcha, /api/auth/refresh).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex.authenticationEntryPoint(restAuthenticationEntryPoint())) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这里最需要理解的是addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)这一句。它把我们自定义的过滤器插入到UsernamePasswordAuthenticationFilter之前意味着请求先进我们的过滤器完成Token校验后后面的Spring Security认证流程自动感知到SecurityContextHolder里的认证信息直接跳转到授权判断阶段。SessionCreationPolicy.STATELESS是JWT方案的标配。它告诉Spring Security不要创建HttpSession也不用Session去维护security上下文彻底走无状态路线。如果你忘了配这一项默认情况下Spring Security还会去创建Session等于保留了有状态的后门认证行为就会变得诡异且难以排查。csrf禁用同样是因为无状态Token方案天然免疫CSRF攻击。CSRF的核心是利用浏览器自动携带Cookie的特性而我们的Token放在自定义Header里攻击者无法跨域伪造所以直接关掉。3.3 自定义AuthenticationEntryPoint未登录时返回什么默认的未登录行为是返回302跳转到登录页那是给传统Web页面用的。在前后端分离项目里接口未认证时应该返回标准的401状态码和JSON错误信息。所以需要一个自定义入口点Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); } }这样一个简单的类解决了前端统一拦截401跳转登录页的需求。我在不止一个项目里见过前端疯狂抱怨后端没有返回标准化错误体根源就是后端忽略了AuthenticationEntryPoint的定制。4. JWT漏洞盘点算法混淆、密钥泄露与过期时间那些坑4.1 算法混淆攻击老版本JWT库最经典的漏洞JWT库发展早期遇到过一类非常危险的攻击篡改Header里的alg字段。最原始的漏洞是alg: none。攻击者把Header里的算法改成none同时去掉签名部分旧版库如果默认支持none算法就会把这种“无签名”的Token当成合法Token直接信任。攻击者可以伪造任意身份畅行无阻。另一类更隐蔽的攻击是算法混淆。应用用的是非对称算法RS256私钥签名、公钥验签攻击者把alg改成HS256然后用服务端的公钥作为HMAC的对称密钥给自己伪造的Token签名。如果服务端库没有严格校验算法与密钥类型直接用公钥去验HMAC签名签名是能通过的攻击就成功了。防御手段也不复杂但必须在起步时就做对升级JWT库到新版本。我用的是jjwt 0.12.x新版本在解析时会强制要求指定验签密钥并且不会接受none算法。在Jwts.parser()里通过verifyWith(key)显式绑定密钥相当于强制锁死了算法族。生产环境禁用一切“自动探测算法”的配置项。4.2 密钥硬编码和弱密钥最大的隐形风险JWT的安全性完全建立在密钥之上但从我接触过的项目看密钥管理恰恰是重灾区。最常见的错误是密钥直接写死在配置代码里甚至提交到Git仓库。一旦仓库泄露等于把整个系统的认证凭据拱手送人。攻击者拿到密钥之后可以自己签发任意用户、任意角色、任意过期时间的Token比解密密码还可怕这是纯“信任根”级别的灾难。密钥强度也是问题。HS256要求32字节以上RS256要求至少2048位的RSA密钥。有些团队图省事复制一个短字符串作为密钥能跑通但防线等同于没有。比较稳妥的做法是密钥放在环境变量、配置中心或云厂商的KMS密钥管理服务里。密钥长度至少256位用openssl rand -base64 48这类命令生成。支持密钥轮换。在Token里加入kidKey ID服务端同时维护多把密钥新Token用新密钥签发旧密钥保留到老Token全部过期再下线。这个方案对大部分业务系统来说性价比很高。4.3 过期时间设太长、签发时间不校验的后患很多小项目贪图方便把Token过期时间设置成7天甚至30天。这么做的后果是一个Token泄露后攻击者有整整一周或一个月的时间窗口可以做坏事。最佳实践是让access token的过期时间保持在15到30分钟让Token的泄漏影响面尽量小。还有个容易被忽略的坑是签发时间iat。部分自定义解析代码只验exp根本不检查iat。比如用户2019年签发的Token用默认值“永久有效”的解析逻辑一直能用。所以解析时必须依赖库自带的过期校验而不是自己临时写一个“仅比较exp”的解析逻辑。我推荐直接使用parseSignedClaims它会内部校验exp如果要手动处理也需要显式调用claims.getExpiration().before(new Date())这类判断。另一个真实生产环境的问题是服务器时钟偏差。如果集群里多台机器的系统时间不同步A机器签发的Token在B机器上可能显示还没生效或已过期。部署时一定要做NTP时间同步这是一项事后很难排查、但预防起来极容易的措施。4.4 Token传递方式不要把Token放在URL里我在一个旧系统里见过把Token拼在URL查询参数上的做法每次用户访问接口Token都会出现在访问日志、网关日志、CDN日志甚至浏览器历史记录里。任何一份日志泄露Token就跟着泄露。正确的做法是只通过Authorization: Bearer token请求头传递。前端存储方面有人用localStorage有人用SessionStorage有人用Cookie。从纯安全角度讲HttpOnlyCookie更稳因为它隔绝了Java脚本的读取路径但使用Cookie又需要面对CSRF虽然我们禁用了CSRF因为JWT放在自定义Header里而非Cookie但如果你把Token放进CookieCSRF防线就得重新拉起来。实际取舍上前后端分离项目用localStorage AuthorizationHeader 短时Token的组合非常常见核心思路就是Token本来就短命localStorage的XSS风险可在可控范围内。我另外建议在所有打印日志的地方对Token做脱敏只保留前四位和后四位避免日志系统成为另一种泄露渠道。规则很简单Token像密码一样对待。5. Token续签方案Refresh Token与滑动过期怎么选5.1 双Token机制access token短命refresh token负责续命因为access token的过期时间被压到15到30分钟就会出现一个很现实的问题用户正在页面上操作Token悄悄过期了下一次请求直接401体验很割裂。所以需要一种让用户无感知续期的机制业界主流就是双Token方案。结构是登录时同时下发两个Token。Access Token有效期短15到30分钟用于访问业务接口。Refresh Token有效期长7到30天只在一个接口使用——换新的Access Token。前端拦截到401响应时可以携带Refresh Token调用刷新接口PostMapping(/api/auth/refresh) public ResponseEntity? refresh(RequestBody RefreshTokenRequest request) { Claims claims tokenProvider.parseToken(request.getRefreshToken()); String username claims.getSubject(); UserDetails userDetails userDetailsService.loadUserByUsername(username); Authentication authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); String newAccessToken tokenProvider.createAccessToken(authentication); String newRefreshToken tokenProvider.createRefreshToken(authentication); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); }刷新接口在SecurityConfig里配置为permitAll因为它需要依靠Refresh Token自身来做认证而不是依赖过期的access token。这是把双Token机制跑通的必要配置。5.2 滑动过期不想维护Refresh Token时的简化方案如果觉得双Token的架构有点重还有一个轻量替代方案——滑动过期。核心逻辑是单Token方案里只要用户活跃就不断给他换发新Token用户长时间不活跃Token自然过期。实现方式在过滤器里做一点增强。验证Token合法后检查剩余有效时间如果低于某个阈值比如5分钟就生成新Token并通过响应头返回给前端long remaining claims.getExpiration().getTime() - System.currentTimeMillis(); long threshold 5 * 60 * 1000L; if (remaining threshold) { String newToken tokenProvider.createAccessToken(authentication); response.setHeader(X-Renewed-Token, newToken); }前端在axios响应拦截器里检测到这个自定义头就替换本地存储的旧Token。这套逻辑的优点是只需要维护一个Token缺点是每次续签都要多一次Token生成和替换而且后端没法做到“强制下线”——只要用户每4分钟刷一次接口Token就永远不会过期这不符合某些安全敏感业务的要求。5.3 续签机制的安全细节不管是双Token还是滑动过期都有几个安全细节容易漏。Refresh Token本质是长期凭据泄露危害比access token大得多。所以refresh token的交易通道要额外加固刷新接口必须限流防止被暴力重放Refresh Token每次刷新后最好轮换一次——旧的作废发新的这样即使某个Refresh Token被偷泄漏窗口也只在两次刷新之间。要支持Refresh Token撤销最常用的方案是Redis黑名单。签发时把Refresh Token的jtiJWT ID存进Redis设置和Token相同的过期时间刷新或登出时把jti标记为失效。校验Refresh Token时先查一下黑名单。当然这只是可选方案对多数业务来说Refresh Token 7天过期已经够用了。6. 登录验证码、SPA集成与生产环境的最后一公里6.1 验证码的生成、存储与登录联动登录接口如果完全开放就很容易被撞库和暴力破解。所以加上验证码或者更简单的滑块、扫码方案成了SPA项目登录流程的标配。验证码实现其实不复杂我用的是Google Kaptcha或EasyCaptcha生成图片在服务端生成时做两件事返回给前端一张图片同时把一个UUID作为Key、验证码内容作为Value存进Redis过期时间5分钟。关键代码逻辑GetMapping(/api/auth/captcha) public CaptchaResponse captcha() { String uuid UUID.randomUUID().toString().replace(-, ); String code generateCaptchaText(); // 4位随机数字或字母 BufferedImage image generateCaptchaImage(code); redisTemplate.opsForValue().set(captcha: uuid, code, 5, TimeUnit.MINUTES); String base64Image Base64.getEncoder().encodeToString(toByteArray(image)); return new CaptchaResponse(uuid, data:image/png;base64, base64Image); }登录接口的改动也很简单先校验验证码再走用户名密码认证public void verifyCaptcha(String uuid, String code) { String saved redisTemplate.opsForValue().get(captcha: uuid); if (saved null || !saved.equalsIgnoreCase(code)) { throw new BusinessException(验证码错误或已过期); } redisTemplate.delete(captcha: uuid); // 一次性使用 }这里有个细节容易被忽略验证码校验通过后一定要立即删除防止同一个验证码被重放多次。如果不删攻击者只需要在5分钟内反复尝试同一个验证码能让暴力破解的成本降低很多。与其说这是验证码更像一个轻量的一次性凭证用过即焚。6.2 SPA前端携带Token的标准姿势与CORS配置前端配合的姿势其实很套路化axios的请求拦截器统一加上请求头axios.interceptors.request.use(config { const token localStorage.getItem(accessToken); if (token) { config.headers.Authorization Bearer ${token}; } return config; });响应拦截器里处理401调用刷新接口后重放失败请求逻辑上没有太特殊的技术含量但有一个小坑需要提前规避——401响应不代表Token一定过期了后端给401的场景还可能包括权限不足。所以前端一般建议根据业务错误码来区分而不是见401就跳登录页。CORS这边也有细节。Spring Security集成CORS容易出问题的点是你配了allowedOrigin(*)但同时又要allowCredentials(true)这两个配置在浏览器规范里是冲突的服务端会直接拒绝。Spring Security推荐的做法是通过CorsConfigurationSource统一配置再把cors配置挂进SecurityFilterChain里这样过滤器链上的CORS处理会早于认证逻辑触发跨域请求的预检OPTIONS请求才能正常放行。6.3 生产环境的最后一公里用户状态、服务间调用与日志JWT是纯无状态的但也带来一个经典问题用户已经被管理员禁用或者删除手中的Token在过期之前仍然有效。解决思路通常是在过滤器里加入轻量级用户状态检查。我上面提到了用缓存优化loadUserByUsername这里就能复用——每个请求从缓存里拿一下用户状态禁用用户直接拒绝。缓存命中时开销非常小却能把“用户被禁用后还能访问接口”这个痛点解决掉。还有密码修改后的Token失效问题。常规做法是签发Token时把用户密码的最后修改时间戳放进claims在过滤器校验时对比数据库中的修改时间。如果密码在Token签发之后被改过直接判定Token失效所有已签发的Token全部作废。这是一个很实用的强制下线手段比维护黑名单简单得多。日志脱敏再提一次。很多系统用户操作日志里会记录完整的请求头或请求参数Token就在其中。我见过安全事故复盘报告泄露渠道居然是Debug日志把Authorization整个打了出来。务必要在日志过滤器里对Authorization做掩码或者干脆在生产环境禁止打印该Header。7. 过滤器顺序、并发登录控制与无状态改造的经验复盘7.1 过滤器顺序为什么必须在UsernamePasswordAuthenticationFilter之前这个部分的坑比较隐蔽。一次请求进入JwtAuthenticationFilter后如果Token合法我们向SecurityContextHolder写入了认证信息。问题在于如果JWT过滤器放在UsernamePasswordAuthenticationFilter后面底层逻辑就变成先尝试用Session或表单参数做一次认证再执行JWT逻辑。对于什么都没有的API请求表单过滤器的结果往往是“未认证”走到FilterSecurityInterceptor时因为上下文里还没有认证信息直接抛权限异常我们的JWT过滤器根本没有执行机会。正确的做法就是在UsernamePasswordAuthenticationFilter前面插队抢先完成认证写入。这也是为什么Spring Security的配置代码里addFilterBefore的参数几乎永远是UsernamePasswordAuthenticationFilter.class——这个类是整个认证流程的关键节点所有自定义登录逻辑都围绕它做插入或替换。7.2 并发登录控制一个Token很难实现的业务需求无状态认证在并发登录控制上天然比较弱。想要实现“同账号最多同时在线两个设备”这样的业务规则需要额外引入会话控制逻辑。一个可行的方案是登录成功时把userId:sessionId映射存进Redis每次请求时校验当前Token对应的sessionId是不是Redis里的最新值如果不是拒掉旧Token。这个方案改造起来对现有JWT体系影响很小只需要在签发Token时生成一个随机sid放进claims过滤器里追加一次Redis比对即可。缺点是一次请求多一次Redis访问但对绝大多数业务来说这个成本可以接受。7.3 服务端无状态改造的边界完全无状态是个伪需求围绕JWT聊到最后我想特别说一个观点过度追求“服务端完全无状态”在真实业务里反而会制造问题。上文提到的验证码、Refresh Token撤销、用户黑名单、并发登录控制哪一个都需要服务端状态。区别只是这些状态从Session里挪到了Redis里而且只保存必要的短期状态而不是整个会话内容。所以我在项目中始终提倡的架构是JWT负责常规认证Redis负责轻量级状态两者分工明确。既享受了JWT无状态带来的扩容优势又能满足业务对强制下线、并发控制、验证码等实际需求。把这条边界想清楚很多设计上的纠结会迎刃而解。8. 结合项目的最终建议如果你正在准备把Spring Security和JWT集成到项目里我给出的验收建议很简单跑通以后不要急着上线部署先做一遍十六个字的自查——改密钥权限改Token过期拉出一份完整的请求日志看看有没有Token泄漏再模拟一次密钥泄露的场景看看能否快速轮换。如果这些都没问题剩下的就可以放心交给时间了。我个人的经验是这套技术栈本身是可靠的绝大多数安全事故都不是JWT本身被破解而是错误使用方式导致的密钥泄露、算法混乱、过期时间形同虚设。把这篇文章里提到的坑一个个填掉你就能得到一个称得上比较稳的认证体系了。