2026/10/26 2:13:26

JWT与Spring Security:系统间信任建立与鉴权实战

JWT与Spring Security:系统间信任建立与鉴权实战 “trust me”这句话放在人和人之间是情感表达放在系统与系统之间就是一道道需要被验证的技术关卡。前端跟后端说“我是登录用户”网关跟业务服务说“这个请求已经校验过了”服务 A 跟服务 B 说“我代表某个用户在操作”——每一句“请相信我”背后都必须有一个可验证的凭证否则整条链路就是裸奔。本文围绕系统间信任关系的建立与验证展开从认证和授权这两个容易混淆的概念讲起逐步拆解 JWT 凭证的结构、签名算法、Spring Security 过滤器链的执行顺序最后落到一套可以跑起来的完整代码登录签发 Token、请求携带 Token、服务端校验 Token、微服务之间透传信任。适合有 Java/Spring 基础、正在做登录鉴权模块或微服务改造的开发者阅读也适合刚开始接触 Spring Security 的同学按步骤动手。文中代码都标注了文件路径可以直接复制到项目里跑通。1. 从 “trust me” 说起系统信任关系的本质1.1 认证与授权两个最容易被混用的概念很多项目代码里到处都是auth这个词但认证Authentication和授权Authorization解决的是两个完全不同的问题。认证回答的是“你是谁”它关心身份的真实性比如用户提交用户名和密码服务端确认这个身份确实存在且凭证匹配。授权回答的是“你能做什么”它发生在认证之后关心权限边界比如这个用户是普通员工还是管理员能不能删除订单。为什么一定要把这两者拆开理解因为它们的失败处理方式完全不同。认证失败是“我不认识你”通常返回 401 未授权状态码前端应该跳转登录页授权失败是“我认识你但你不能这么做”返回 403 禁止访问前端应该提示无权操作。如果代码里把两者揉在一起就会出现“权限不足却把用户踢回登录页”这种体验很差的交互排查问题时也很难区分到底是凭证无效还是权限不够。在实际项目里我建议把认证和授权放在两套代码里分别处理认证交给过滤器Filter在请求进入业务逻辑之前完成授权交给方法注解或路径规则在业务层完成。这样责任清晰日志也好打。1.2 信任链一次请求究竟经过几道关卡用户点击一次“查看我的订单”这个请求在到达业务代码之前其实要走过一条信任链。第一关是入口层可能由网关做初步的 Token 解析和格式校验第二关是应用的过滤器链校验签名、有效期、签发方第三关是方法级的权限判断确认当前身份是否有order:read权限第四关是数据层确认这条数据确实属于当前用户防止越权访问这个叫水平越权是很多线上事故的源头。这条链上任何一关被跳过整条链路就不再可信。这也是为什么“前端隐藏了按钮”永远不能算作权限控制——请求可以直接用工具构造绕过所有前端逻辑。理解信任链的意义在于你需要在设计阶段就想清楚每一关校验什么、失败后怎么响应、日志记录哪些字段。否则等接口上线之后再来补往往会出现“同一个 Token 在网关能过、在业务服务过不了”这类让人抓狂的不一致问题。1.3 为什么用 Token 承载信任而不是 Session传统的 Session 方案是服务端保存会话状态客户端只拿一个无意义的 SessionId。这个方案在单机应用里非常成熟但到了多实例部署就有了麻烦会话数据要么共享存储引入 Redis 依赖要么做粘性会话负载均衡变得不灵活。Token 方案反过来把会话状态编码进凭证本身服务端只负责校验签名不需要保存任何状态。这带来了两个直接好处水平扩展变得简单任意实例都能独立完成校验跨服务传递变得自然服务 A 拿到 Token 可以直接交给服务 B。但代价同样明显Token 一旦签发在有效期内就是有效的服务端无法“主动撤销”单个 Token。所以必须配合较短的有效期和刷新机制关键操作还要额外校验。这一点在后面讲最佳实践时会重点展开。2. 环境准备与项目结构2.1 技术栈与版本说明本文示例基于 Spring Boot 3.x Spring Security 6.x JDK 17 编写。这个组合是目前新项目比较常见的选择其中 Spring Security 6 相比 5.x 有较大变化最典型的是WebSecurityConfigurerAdapter被移除改为声明SecurityFilterChainBean 的方式进行配置方法链式调用也做了调整。需要特别说明的是不同版本之间 API 差异较大请按你项目的实际版本调整本文示例。如果你还在 Spring Boot 2.7 及以下版本配置写法需要用WebSecurityConfigurerAdapter继承方式或者用SecurityFilterChain但换用authorizeRequests()而不是authorizeHttpRequests()。JWT 库方面本文使用jjwt0.12.x 版本它在 0.11.x 的基础上重命名了大量方法setSubject改为subject、parserBuilder改为parser等如果你的工程里是旧版本编译时方法找不到基本都是这个原因。其他环境要求JDK 17 及以上Spring Boot 3 强制要求构建工具 Maven 3.8 或 Gradle 7.5IDE 用 IntelliJ IDEA 或 VS Code 都可以接口调试用 Postman 或 curl 命令。2.2 依赖引入新建一个 Spring Boot 工程在pom.xml中引入下面几项依赖。注意jjwt需要同时引入 api、impl、jackson 三个坐标只引 api 会在运行时找不到序列化实现。!-- 文件路径pom.xml -- dependencies !-- Web 基础能力提供内嵌容器与 MVC 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 安全框架提供过滤器链、密码加密、权限注解 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency !-- JWT 核心 API用于构建和解析 Token -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency !-- 参数校验登录接口用得上 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies版本号如果和你项目其他依赖有冲突可以用dependencyManagement统一管理避免出现NoClassDefFoundError这类穿透到运行期才暴露的问题。2.3 项目目录结构为了避免后面代码贴出来找不到位置先把整体结构列清楚。包名你可以按自己公司规范调整本文示例统一用com.example.trust。src/main/java/com/example/trust ├── TrustApplication.java // 启动类 ├── config │ ├── SecurityConfig.java // 过滤器链配置 │ └── JwtProperties.java // 密钥与有效期配置绑定 ├── controller │ ├── AuthController.java // 登录接口 │ └── OrderController.java // 受保护的示例业务接口 ├── filter │ └── JwtAuthenticationFilter.java // Token 校验过滤器 ├── service │ ├── JwtService.java // Token 签发与解析 │ └── UserService.java // 用户查询示例内存实现 └── model ├── LoginRequest.java // 登录入参 └── LoginResponse.java // 登录返回 src/main/resources └── application.yml // 密钥、过期时间等配置这个结构刻意把“签发/解析能力”抽成JwtService“校验时机”放进过滤器“权限规则”放进配置类三个关注点分离。后面如果要做密钥轮换或者多签发方支持只需要改动JwtService其他代码不受影响。3. JWT 与信任凭证的核心原理3.1 三段式结构它到底存了什么JWTJSON Web Token是一个用点号分隔的三段字符串形如xxxxx.yyyyy.zzzzz。三段分别是 Header、Payload 和 Signature每一段都是 Base64URL 编码。Header 描述签名算法和类型通常是{alg:HS256,typ:JWT}。Payload 存放声明Claims分为标准声明和自定义声明标准的有sub主体通常是用户标识、iat签发时间、exp过期时间、iss签发方自定义的可以放用户角色、租户 ID 等业务信息。Signature 是对前两段内容的签名用于防篡改。这里有一个新手最常见的误区JWT 的 Payload 只是 Base64 编码不是加密。任何人拿到 Token随便找个在线工具就能解出里面的内容。所以绝对不要往 Payload 里塞手机号、身份证号、密码这类敏感信息。Token 能保证的是“没被改过”而不是“别人看不到”。另一个常见误区是把角色、权限全部塞进 Token导致 Token 体积巨大每次请求都要带上几百字节甚至上 KB 的头部。更麻烦的是权限变更后旧 Token 里的权限还是旧的必须等它过期才生效。我的建议是 Token 里只放最少量的标识用户 ID、租户 ID权限实时从缓存或数据库查。3.2 签名算法与密钥长度本文使用的是 HMAC-SHA256HS256对称加密签发和校验用同一个密钥。它的优点是性能好、实现简单缺点是所有校验方都持有密钥等于所有校验方都能签发。如果你的系统里调用方很多、安全边界复杂应该考虑 RS256 这种非对称算法私钥签发、公钥校验校验方拿不到签发能力。密钥长度是一个必须重视的点。Keys.hmacShaKeyFor()对密钥有最小长度要求HS256 要求至少 256 位32 字节。如果你图省事写一句secret运行时就会抛WeakKeyException。这不是框架在刁难你而是短密钥确实容易被暴力破解。密钥的管理方式更需要强调绝对不能硬编码在代码里提交到仓库。正确做法是通过环境变量、配置中心或密钥管理服务注入并且区分环境。生产密钥一旦泄露攻击者可以伪造任意用户的 Token这是最高级别的安全事故。所以下面示例里我会通过application.yml占位符 环境变量注入的方式来写同时提醒你在生产环境做替换。3.3 Spring Security 过滤器链的执行顺序Spring Security 的核心是一条过滤器链请求按顺序穿过这些过滤器每个过滤器负责一类事情。跟本文相关的关键是认证过滤器要在UsernamePasswordAuthenticationFilter之前执行这样自定义的 Token 校验才能在默认表单登录逻辑之前接管请求。请求进来后的处理顺序大致是这样的SecurityContextHolderFilter负责清理和加载安全上下文。我们的JwtAuthenticationFilter解析请求头校验 Token把身份信息写入SecurityContext。AuthorizationFilter根据配置的路径规则判断当前身份是否有权访问。进入 DispatcherServlet执行 Controller 逻辑。这个顺序解释了为什么很多同学遇到“明明 Token 是对的接口还是返回 401”——如果你的过滤器加在了错误的位置比如加在AuthorizationFilter之后那么权限判断的时候SecurityContext还是空的自然判定为未认证。还有一个隐含细节SecurityContext默认是基于 ThreadLocal 的一次请求一个线程所以过滤器里写入的身份信息在 Controller 里能读到。但如果你在业务代码里开了新线程做异步处理是读不到这个上下文的需要手动传递。4. 完整实战从登录到校验的闭环4.1 配置项与属性绑定先在application.yml中定义 JWT 相关配置。注意${JWT_SECRET:...}这种写法表示优先读环境变量读不到再用默认值方便本地开发。# 文件路径src/main/resources/application.yml server: port: 8080 jwt: # 生产环境请通过环境变量 JWT_SECRET 注入不要提交到仓库 secret: ${JWT_SECRET:local-dev-only-secret-key-please-change-32bytes} # 访问令牌有效期单位秒示例为 30 分钟 expire-seconds: 1800 # 签发方标识便于多系统区分 issuer: trust-demo对应的属性绑定类如下。使用ConfigurationProperties比散落的Value更好维护字段多了以后尤其明显。// 文件路径src/main/java/com/example/trust/config/JwtProperties.java package com.example.trust.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Component ConfigurationProperties(prefix jwt) public class JwtProperties { /** 签名密钥长度至少 32 字节 */ private String secret; /** 访问令牌有效期秒 */ private long expireSeconds; /** 签发方标识 */ private String issuer; public String getSecret() { return secret; } public void setSecret(String secret) { this.secret secret; } public long getExpireSeconds() { return expireSeconds; } public void setExpireSeconds(long expireSeconds) { this.expireSeconds expireSeconds; } public String getIssuer() { return issuer; } public void setIssuer(String issuer) { this.issuer issuer; } }4.2 Token 签发与解析服务这是整套逻辑的核心。签发时把用户 ID 放进subject角色放进自定义声明解析时先验签再取声明任何异常都统一处理成“Token 无效”。// 文件路径src/main/java/com/example/trust/service/JwtService.java package com.example.trust.service; import com.example.trust.config.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.List; Service public class JwtService { private final JwtProperties properties; private final SecretKey key; public JwtService(JwtProperties properties) { this.properties properties; // 密钥长度不足 32 字节时会抛出 WeakKeyException这里提前暴露问题 this.key Keys.hmacShaKeyFor( properties.getSecret().getBytes(StandardCharsets.UTF_8)); } /** * 签发访问令牌 * * param userId 用户唯一标识 * param roles 角色列表 * return 签名后的 JWT 字符串 */ public String issueToken(String userId, ListString roles) { Instant now Instant.now(); Instant expireAt now.plusSeconds(properties.getExpireSeconds()); return Jwts.builder() .subject(userId) .issuer(properties.getIssuer()) .issuedAt(Date.from(now)) .expiration(Date.from(expireAt)) .claim(roles, roles) .signWith(key) .compact(); } /** * 解析并校验令牌签名错误、过期、格式不对都会抛出 JwtException */ public Claims parse(String token) throws JwtException { return Jwts.parser() .verifyWith(key) .requireIssuer(properties.getIssuer()) .build() .parseSignedClaims(token) .getPayload(); } }几处值得说明的地方。signWith(key)会依据密钥长度自动选择算法32 字节对应 HS25664 字节对应 HS512不用手动指定。requireIssuer()是校验签发方的关键多系统共存时能挡住“别的系统签发的 Token 拿来用”这种越权场景。parseSignedClaims()一次性完成验签、过期检查、签发方检查比手动逐项判断更不容易遗漏。如果你项目里是jjwt0.11.x等价写法是Jwts.parserBuilder().setSigningKey(key).requireIssuer(...).build().parseClaimsJws(token).getBody()方法名不一样逻辑完全一致。4.3 密码加密与用户查询密码绝对不能用明文或 MD5 存储。Spring Security 提供的BCryptPasswordEncoder是推荐选择它自带随机盐同一个密码每次加密结果都不一样并且计算成本可调能显著提高离线爆破的代价。// 文件路径src/main/java/com/example/trust/service/UserService.java package com.example.trust.service; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; /** * 示例用的内存用户存储真实项目请替换为数据库查询 */ Service public class UserService { private final PasswordEncoder passwordEncoder; /** 用户名 - {加密后的密码, 角色列表} */ private final MapString, UserRecord users; public UserService(PasswordEncoder passwordEncoder) { this.passwordEncoder passwordEncoder; // 启动时把示例密码加密避免代码里出现明文 this.users Map.of( admin, new UserRecord( passwordEncoder.encode(admin123), List.of(ADMIN)), alice, new UserRecord( passwordEncoder.encode(alice123), List.of(USER)) ); } public OptionalUserRecord findByUsername(String username) { return Optional.ofNullable(users.get(username)); } public boolean matches(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); } public record UserRecord(String encodedPassword, ListString roles) { } }这里用record承载用户数据代码更紧凑。真实项目里findByUsername应该走数据库或缓存并且要注意无论用户是否存在都不要在响应里暴露差异否则攻击者可以枚举出哪些用户名是有效的。登录失败统一返回“用户名或密码错误”。4.4 认证过滤器把 Token 变成安全上下文这个过滤器是整个机制的中枢。它做三件事从请求头取 Token、校验并解析、把身份写入上下文。注意它不能抛异常中断流程而是要在校验失败时保持上下文为空交给后续的授权环节去拒绝。// 文件路径src/main/java/com/example/trust/filter/JwtAuthenticationFilter.java package com.example.trust.filter; import com.example.trust.service.JwtService; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final Logger log LoggerFactory.getLogger(JwtAuthenticationFilter.class); private static final String HEADER Authorization; private static final String PREFIX Bearer ; private final JwtService jwtService; public JwtAuthenticationFilter(JwtService jwtService) { this.jwtService jwtService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(HEADER); // 没有携带令牌时直接放行由后续授权规则决定是否拒绝 if (header null || !header.startsWith(PREFIX)) { chain.doFilter(request, response); return; } String token header.substring(PREFIX.length()).trim(); try { Claims claims jwtService.parse(token); SuppressWarnings(unchecked) ListString roles claims.get(roles, List.class); ListSimpleGrantedAuthority authorities roles null ? List.of() : roles.stream() .map(r - new SimpleGrantedAuthority(ROLE_ r)) .toList(); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug(令牌校验通过, userId{}, claims.getSubject()); } catch (JwtException | IllegalArgumentException e) { // 令牌无效时不写入上下文交由授权环节返回 401 SecurityContextHolder.clearContext(); log.warn(令牌校验失败, reason{}, e.getMessage()); } chain.doFilter(request, response); } }继承OncePerRequestFilter而不是直接实现Filter是为了保证同一个请求内只执行一次避免异步转发forward时重复解析。日志里只记录失败原因不要把完整 Token 打出来因为日志系统往往权限更宽Token 泄露等同于身份泄露。4.5 安全配置路径规则与无状态会话// 文件路径src/main/java/com/example/trust/config/SecurityConfig.java package com.example.trust.config; import com.example.trust.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分离场景下无状态接口不使用 Cookie 会话可关闭 CSRF .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/auth/login, /health).permitAll() .requestMatchers(HttpMethod.GET, /orders/**).hasRole(USER) .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }关于csrf().disable()需要说明清楚只有在你完全不依赖 Cookie 传递会话凭证的前提下才可以关闭。如果系统同时用 Cookie 做会话且关闭了 CSRF 防护就会暴露跨站请求伪造风险。前后端分离 Bearer Token 模式之所以能关是因为浏览器不会自动携带 Token攻击者构造的跨站请求带不上凭证。如果你的系统是传统服务端渲染加表单提交务必保持 CSRF 开启。4.6 登录接口与受保护接口// 文件路径src/main/java/com/example/trust/controller/AuthController.java package com.example.trust.controller; import com.example.trust.service.JwtService; import com.example.trust.service.UserService; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; RestController RequestMapping(/auth) public class AuthController { private final UserService userService; private final JwtService jwtService; public AuthController(UserService userService, JwtService jwtService) { this.userService userService; this.jwtService jwtService; } PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { return userService.findByUsername(request.username()) .filter(u - userService.matches( request.password(), u.encodedPassword())) .map(u - { String token jwtService.issueToken( request.username(), u.roles()); return ResponseEntity.ok(Map.of( token, token, tokenType, Bearer)); }) // 用户不存在与密码错误返回同一提示避免用户名枚举 .orElseGet(() - ResponseEntity.status(401) .body(Map.of(message, 用户名或密码错误))); } public record LoginRequest(NotBlank String username, NotBlank String password) { } }// 文件路径src/main/java/com/example/trust/controller/OrderController.java package com.example.trust.controller; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; RestController RequestMapping(/orders) public class OrderController { GetMapping public MapString, Object list(Authentication authentication) { // 从安全上下文中取当前用户不要从请求参数里取 userId String userId String.valueOf(authentication.getPrincipal()); return Map.of( userId, userId, orders, List.of( Map.of(orderId, A1001, amount, 199), Map.of(orderId, A1002, amount, 88) )); } }注意OrderController里取当前用户的方式从Authentication里拿而不是从请求参数里拿。如果接口设计成GET /orders?userIdxxx那就是典型的水平越权漏洞——只要改一下参数就能看到别人的订单。真实业务里查询条件必须强制附加当前用户 ID 作为过滤条件。4.7 运行与验证启动应用后用 curl 依次验证三个场景。先拿一个合法 Token# 1. 登录获取令牌 curl -X POST http://localhost:8080/auth/login \ -H Content-Type: application/json \ -d {username:alice,password:alice123}预期返回类似下面的结构token字段就是三段式的 JWT{ token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs..., tokenType: Bearer }然后带 Token 访问受保护接口# 2. 携带令牌访问订单接口 curl http://localhost:8080/orders \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs...预期返回当前用户和订单列表。接着验证失败场景# 3. 不带令牌预期 401 curl -i http://localhost:8080/orders # 4. 篡改令牌最后一段预期 401验签失败 curl -i http://localhost:8080/orders -H Authorization: Bearer xxx.yyy.zzz # 5. 用 alice 的令牌访问管理员接口预期 403认证通过但授权不足 curl -i http://localhost:8080/admin/users \ -H Authorization: Bearer alice的令牌这三个失败的用例很关键很多同学只测通了一条成功路径就认为做完了。401 和 403 的区分、签名篡改能否被拦住才是判断鉴权是否真的生效的标准。5. 服务间信任微服务调用如何传递身份5.1 Token 透传最简单也最容易踩坑的方案服务 A 收到用户请求后要调用服务 B最直接的做法是把原始 Token 放进请求头继续往下传。服务 B 用同一个密钥或公钥校验就能拿到同一个身份。这种方式实现成本低链路清晰。但它有两个必须注意的点。第一Token 的有效期在链路中持续消耗如果 A 调用 B 时 Token 只剩 10 秒B 处理稍慢就会 401。建议在调用前检查剩余有效期不足时提前刷新。第二Token 会流经多个服务任何一个服务的日志、链路追踪系统如果全量记录请求头Token 就会散落在各处。所以要在日志配置里对Authorization头做脱敏。5.2 内部服务签名不依赖用户身份的调用有些调用并不代表某个用户比如定时任务触发数据同步。这时不应该伪造用户 Token而是用服务身份。常见做法是给每个服务分配一对clientId/clientSecret调用时用密钥签名请求签名内容包含时间戳、请求体和随机数被调方校验签名并检查时间戳窗口比如 ±5 分钟防止重放。这块实现相对复杂工程上更常见的替代方案是引入统一的服务网格或内部网关统一处理业务代码不用关心。无论用哪种方式原则是一致的内部网络不等于可信网络涉及到数据变更的接口仍然需要校验调用方身份。5.3 密钥轮换让信任关系可以平滑更新密钥一旦固定不变泄露风险就随使用时长线性上升。轮换的难点在于签发方换新密钥的瞬间旧 Token 还没过期校验方如果只认新密钥所有在线用户会集体掉线。解决办法是让校验方同时信任多个密钥通过 Header 里的kid密钥标识字段选择对应的密钥。轮换流程是先让所有校验方上线“双密钥支持”再切换签发方使用新密钥等旧 Token 全部过期超过一个完整有效期后移除旧密钥。整个过程用户无感知。如果你的系统规模还不大至少要做到密钥可以通过配置中心热更新而不是改代码重新发版。6. 常见问题与排查清单鉴权模块的问题表现形式很集中但原因往往不止一种。下面这张表按现象归类遇到报错时可以逐条比对。问题现象常见原因解决思路启动报WeakKeyException密钥长度不足 32 字节换用足够长度的密钥或改用Keys.secretKeyFor(SignatureAlgorithm.HS256)生成所有请求都 401过滤器未注册或顺序错误检查addFilterBefore是否加在UsernamePasswordAuthenticationFilter之前所有请求都 401Authorization头缺少Bearer前缀前端拼接时统一封装服务端严格按前缀截取明明登录成功但访问接口 403hasRole(USER)与授权值不匹配过滤器里加的权限前缀要和hasRole约定一致hasRole(USER)实际校验ROLE_USER上线后部分用户频繁掉线多实例密钥不一致密钥统一从配置中心下发不写死在各自配置文件时间一过零点就大面积 401服务器时区或时间不同步统一使用 UTC 时间部署 NTP 时间同步旧 Token 在权限变更后仍有旧权限权限被写进了 Token Payload改为 Token 只放用户 ID权限实时查询缓存报InvalidClaimException: iss多系统共用密钥但签发方不同签发时设置 issuer校验时用requireIssuer限制日志出现大量验签失败可能是扫描器探测或密钥被误换结合来源 IP 和错误分布判断必要时做频率限制排查时建议按“请求头是否正确 → 过滤器是否执行 → 上下文是否写入 → 授权规则是否匹配”这个顺序走。一个很实用的小技巧是在过滤器里打一行 debug 日志把SecurityContext里的权限列表输出出来能快速定位是认证没通过还是授权没配对。7. 最佳实践与工程建议有效期设计要有层次。访问令牌有效期短比如 30 分钟刷新令牌有效期长比如 7 天刷新令牌一次性使用并且可撤销。这样既控制了泄露后的风险窗口又不会让用户频繁重新登录。刷新令牌必须存储服务端状态否则就失去了“可撤销”这个核心价值。密钥管理纳入发布流程。密钥不要放在代码仓库、不要放在前端、不要出现在日志。生产环境通过环境变量或密钥管理服务注入不同环境使用不同密钥并且把密钥轮换写进运维手册。这条做不好前面所有代码都是白写。错误响应要统一但不要泄露细节。对外统一返回“用户名或密码错误”“登录状态已失效”不要把“用户不存在”“签名不匹配”“Token 已过期”这些内部原因直接透给前端。详细原因写日志接口只给用户能理解的信息。日志脱敏要做成默认行为。请求头、请求体、响应体里都可能带凭证。建议在日志框架层面统一配置敏感字段过滤而不是靠每个开发者记得手动脱敏——靠人记的事情迟早会漏。权限校验要有兜底策略。路径规则容易写漏建议默认拒绝anyRequest().authenticated()作为兜底而不是默认放行。新增接口时如果忘记配置规则默认是受保护的这比默认开放安全得多。避免过度依赖前端控制。按钮隐藏、菜单不渲染都只是体验优化不是安全措施。所有涉及数据读取和状态变更的接口都必须在服务端做身份和归属校验尤其是“根据 ID 查询详情”这类接口必须校验数据是否属于当前用户。给测试留出可验证的失败路径。写单元测试时除了验证成功场景至少要覆盖过期 Token、篡改签名、缺少 Token、权限不足这四种。这几条用例能在重构过滤器链时第一时间发现回归问题。生产变更要稳妥。修改鉴权逻辑属于高风险变更建议先在测试环境完整验证采用灰度发布保留快速回滚方案。涉及用户数据的权限规则调整最好提前通知相关方避免上线后出现大面积无法访问的情况。8. 继续深入的方向把上面的代码跑通之后你已经具备了一套可用的无状态认证基础。接下来值得继续研究的方向有几个一是权限模型升级从简单的角色RBAC走向基于资源和操作的细粒度权限如order:read、order:delete配合方法级注解PreAuthorize使用二是单点登录与多端统一研究 OAuth 2.1 和 OIDC 的授权码流程理解第三方登录场景下信任是如何建立的三是令牌的主动失效通过维护一个短期的失效列表或版本号解决“用户改密码后旧 Token 仍然有效”的问题。如果你想现在就动手加固手上的项目有三个动作性价比最高把硬编码的密钥挪到环境变量检查所有“按 ID 查详情”的接口是否做了归属校验给鉴权模块补上过期、篡改、越权三类测试用例。这三件事花不了多少时间但能挡掉大部分真实存在的风险。信任这件事在系统里从来不是靠一句“请相信我”成立的它靠的是每一个环节都把该做的校验老老实实做完。代码写到位了别人才敢信。