资源服务器如何对 JWT 进行验签
🎯 核心结论
资源服务器对 JWT 的校验分两层,且几乎不需要频繁调用 Auth 接口:
- 签名验证 :通过 Auth Server 公开的 JWKS 端点 (
/oauth2/jwks)拉取公钥,拉取后本地缓存,后续验签完全在本地完成 - 业务校验 :
exp过期时间、iss签发者、aud受众、scope/authorities等 claims,全部在资源服务器本地解析 JWT 后校验 ,不经过任何网络调用
也就是说:资源服务器只有"第一次"或"公钥轮换"时才会真正请求 Auth 接口,日常请求的校验是纯本地操作------这是 JWT 无状态认证性能优势的根本来源。
⚠️ 先纠正一个常见误解
"资源服务器每次收到 Token 都要调 Auth 接口验签" ------ 错 。
如果是这样,Auth Server 会成为整个系统的性能瓶颈和单点故障,JWT 的"自包含"特性也就没有意义了。Spring Security 的实际做法是:
- 资源服务器启动或首次收到 JWT 时,向 Auth Server 的 JWKS 端点发起 一次 HTTP 请求,拉取公钥集合(JWK Set)
- 公钥缓存在本地(默认内存缓存,可配置 TTL)
- 后续所有请求都拿缓存的公钥在本地做签名校验
- 当 JWT 的
kid在缓存中找不到(说明 Auth Server 已换新钥),才会触发一次 JWKS 重新拉取
Auth Server 真正被调用的场景只剩三类:用户登录签发 Token、用 refresh_token 刷新、Token 吊销(introspection 端点,可选)。
🛠️ 基础配置:两种方式
方式一:issuer-uri(推荐,自动发现)
yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
Spring Boot 启动时会请求 https://auth.example.com/.well-known/openid-configuration,从返回的 OIDC 发现文档中自动读取 jwks_uri 字段 ,据此构建 NimbusJwtDecoder。
额外好处 :decoder 会同时校验 JWT 的 iss claim 必须等于配置的 issuer-uri,防止跨环境令牌误用。
⚠️ 启动时会做一次连通性检查(
JwtDecoder的Supplier是懒加载,首次使用才真正发起发现请求),若 Auth Server 不可达会在第一次请求时报错。
方式二:jwk-set-uri(直接指定)
yaml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/oauth2/jwks
# 可选:显式指定 issuer 校验
issuer-uri: https://auth.example.com
跳过发现文档步骤,直接指向 JWKS 端点。Auth Server 未实现 OIDC Discovery 时使用 ;缺点是 iss 校验需要手动加 validator。
两种方式对比
| 维度 | issuer-uri |
jwk-set-uri |
|---|---|---|
| 配置复杂度 | 低(只写域名) | 中(要自己拼出 JWKS 路径) |
自动校验 iss |
✅ 是 | ❌ 需手动加 validator |
| 依赖 OIDC Discovery | ✅ 是 | ❌ 否 |
| 适用场景 | 标准 Spring Authorization Server、Keycloak | 简化版 Auth Server、内网直连 |
🔄 完整校验时序
Auth Server JWKS JWK 缓存 NimbusJwtDecoder JwtAuthenticationProvider BearerTokenAuthenticationFilter 用户请求 Auth Server JWKS JWK 缓存 NimbusJwtDecoder JwtAuthenticationProvider BearerTokenAuthenticationFilter 用户请求 #mermaid-svg-aJcjG3NmcKg9dUaX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aJcjG3NmcKg9dUaX .error-icon{fill:#552222;}#mermaid-svg-aJcjG3NmcKg9dUaX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aJcjG3NmcKg9dUaX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aJcjG3NmcKg9dUaX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aJcjG3NmcKg9dUaX .marker.cross{stroke:#333333;}#mermaid-svg-aJcjG3NmcKg9dUaX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aJcjG3NmcKg9dUaX p{margin:0;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aJcjG3NmcKg9dUaX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .sequenceNumber{fill:white;}#mermaid-svg-aJcjG3NmcKg9dUaX #sequencenumber{fill:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-aJcjG3NmcKg9dUaX .messageText{fill:#333;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX .labelText,#mermaid-svg-aJcjG3NmcKg9dUaX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .loopText,#mermaid-svg-aJcjG3NmcKg9dUaX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-aJcjG3NmcKg9dUaX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aJcjG3NmcKg9dUaX .noteText,#mermaid-svg-aJcjG3NmcKg9dUaX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-aJcjG3NmcKg9dUaX .actorPopupMenu{position:absolute;}#mermaid-svg-aJcjG3NmcKg9dUaX .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-aJcjG3NmcKg9dUaX .actor-man circle,#mermaid-svg-aJcjG3NmcKg9dUaX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-aJcjG3NmcKg9dUaX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt缓存命中缓存未命中(首次或轮换) 1. Authorization: Bearer eyJhbGc...2. 交给 JwtAuthenticationProvider3. decoder.decode(token)4. Base64 解析 Header,读取 kid / alg5. 根据 kid 查找公钥6a. 返回缓存的公钥(本地,零网络)6b. GET /oauth2/jwks7. 返回 JWK Set8. 返回新公钥9. 用公钥验签 RSA/ECDSA10. 校验 exp / nbf / iss / aud11. 跑自定义 OAuth2TokenValidator12. 返回 Jwt 对象13. claims → GrantedAuthority14. 返回 Authentication15. 放入 SecurityContext,请求放行
任何一个环节失败(签名不匹配、过期、issuer 不符、audience 不符),NimbusJwtDecoder 都会抛出 JwtValidationException 或 BadJwtException,由 BearerTokenAuthenticationEntryPoint 统一返回 401 ,并在 WWW-Authenticate: Bearer error="invalid_token" 头中给出原因。
📐 代码层自定义:校验规则与缓存
Spring Boot 的自动配置已足够日常使用,但要精细控制 aud/scope 校验或 JWKS 缓存策略,需要显式定义 JwtDecoder:
java
@Configuration
public class ResourceServerConfig {
/**
* 自定义 JwtDecoder:显式指定 JWKS 地址 + 缓存 + 校验链
*/
@Bean
public JwtDecoder jwtDecoder() {
// 1. 基于 JWKS 端点构建 decoder(底层是 Nimbus 的 JWKSource,自带缓存)
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256) // 明确签名算法,拒绝 alg 混淆攻击
.build();
// 2. 组合校验器:时间戳(默认) + issuer + audience + 自定义
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(30)), // 允许30秒时钟偏移
new JwtIssuerValidator("https://auth.example.com"), // iss 必须匹配
new JwtAudienceValidator("admin-bff"), // aud 必须包含
jwt -> { // 自定义:必须有 admin.read scope
Collection<String> scopes = jwt.getClaimAsStringList("scope");
return (scopes != null && scopes.contains("admin.read"))
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_scope", "缺少 admin.read 权限", null));
}
);
decoder.setJwtValidator(validator);
return decoder;
}
/**
* SecurityFilterChain:启用资源服务器模式
*/
@Bean
public SecurityFilterChain resourceServerChain(ServerHttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/**").hasAuthority("SCOPE_admin.read")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()) // 使用上面的 JwtDecoder Bean
// 自定义 401 响应体
.authenticationEntryPoint((request, response, ex) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":401,\"msg\":\"令牌无效或已过期\"}");
})
);
return http.build();
}
}
JWKS 缓存与密钥轮换的实战要点
Spring Security 默认行为 :NimbusJwtDecoder 内部使用 Nimbus 的 JWKSource,首次调用会拉取一次 JWKS,之后始终用缓存 ------这意味着如果你在 Auth Server 轮换了密钥,资源服务器不会自动感知 ,会出现 kid 找不到导致的 401 风暴。
生产环境三种解决方案:
方案 A:定期刷新(最简单)
java
// Nimbus 提供 RefreshableJWKSource,按时间窗口自动重拉
JWKSetSource<SecurityContext> remote = new RemoteJWKSet<>(url, config);
JWKSource<SecurityContext> refreshable =
new RefreshableJWKSource<>(remote, Duration.ofMinutes(30)); // 30分钟刷新一次
方案 B:缓存 TTL + kid 未命中时触发刷新
java
// 通过 NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder.cache() 挂接 Caffeine
NimbusJwtDecoder.withJwkSetUri(jwksUri)
.cache(caffeineCache) // 配合过期时间
.build();
方案 C:Auth Server 轮换时保留旧钥 (这是 Auth 侧的责任)
按最佳实践,Auth Server 轮换签名密钥时,JWK Set 中必须同时保留新旧两把公钥,给资源服务器留 1-2 天的缓存刷新窗口,等旧 Token 全部过期后再下线旧钥。
🔍 资源服务器到底校验了什么
一张表看清 JWT 在资源服务器侧经历的全部检查:
| 校验项 | 数据来源 | 失败后果 | 是否需网络 |
|---|---|---|---|
| 签名 | JWKS 公钥(缓存) | InvalidSignatureException → 401 |
仅首次/轮换 |
| 算法一致性 | JWT Header alg vs 配置 |
拒绝解析 → 401 | 否 |
exp 过期 |
JWT Payload | JwtValidationException → 401 |
否 |
nbf 生效时间 |
JWT Payload | 同上 → 401 | 否 |
iss 签发者 |
JWT Payload vs 配置 | 校验失败 → 401 | 否 |
aud 受众 |
JWT Payload vs 配置 | 校验失败 → 401 | 否 |
scope/authorities |
JWT Payload → GrantedAuthority | 403(认证通过但无权限) | 否 |
| Token 吊销(可选) | Auth /oauth2/introspect |
401 | ✅ 每次请求(代价大) |
| 注意最后一行 :JWT 默认不检查吊销状态 ,签名有效期内即使 Auth Server 已"拉黑"该 Token,资源服务器也会放行。如果业务要求实时吊销,需要改用不透明令牌(Opaque Token)+ introspection 端点 模式------那种模式下资源服务器每次请求都要调 Auth 接口,这是与 JWT 的根本取舍。 |
💡 回到本项目
本项目中 Admin API 作为资源服务器的接入方式:
yaml
# admin-api 的 application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
- 首次 请求到达时,自动请求
https://auth.example.com/.well-known/openid-configuration,发现jwks_uri - 首次 从
/oauth2/jwks拉取 Gateway 令牌对应的 RSA 公钥,缓存到本地 - 后续 所有请求:Gateway 透传来的
Authorization: Bearer xxx在本地完成签名校验 + claims 校验,性能开销可忽略 - Auth Server 重启/密钥轮换 时:只要 JWK Set 里保留了旧钥,已缓存公钥继续有效,无感知切换;否则需重启资源服务或配置定时刷新
一句话总结:资源服务器的"验签"本质上是"本地用缓存的 Auth 公钥验签名 + 本地校验 claims",调用 Auth 接口只是首次发现端点和拉取公钥时的初始化动作,绝非常规路径。