资源服务器如何对 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(推荐,自动发现)
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(直接指定)
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、内网直连 |
🔄 完整校验时序
任何一个环节失败(签名不匹配、过期、issuer 不符、audience 不符),NimbusJwtDecoder 都会抛出 JwtValidationException 或 BadJwtException,由 BearerTokenAuthenticationEntryPoint 统一返回 401,并在 WWW-Authenticate: Bearer error="invalid_token" 头中给出原因。
📐 代码层自定义:校验规则与缓存
Spring Boot 的自动配置已足够日常使用,但要精细控制 aud/scope 校验或 JWKS 缓存策略,需要显式定义 JwtDecoder:
@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:定期刷新(最简单)
// Nimbus 提供 RefreshableJWKSource,按时间窗口自动重拉
JWKSetSource<SecurityContext> remote = new RemoteJWKSet<>(url, config);
JWKSource<SecurityContext> refreshable =
new RefreshableJWKSource<>(remote, Duration.ofMinutes(30)); // 30分钟刷新一次
方案 B:缓存 TTL + kid 未命中时触发刷新
// 通过 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 作为资源服务器的接入方式:
# 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 接口只是首次发现端点和拉取公钥时的初始化动作,绝非常规路径。
转载自 CSDN-专业IT技术社区



