Spring Authorization Server令牌生成策略:JWT与Opaque Token选型及自定义实战
如果你正在搭 Spring Security OAuth2 授权服务器大概率会在某个版本迭代后被同一个问题卡住访问令牌到底用 JWT 还是用默认的 Opaque Token这个选择不像选框架那么简单它直接决定了资源服务器怎么验签、令牌怎么撤销、自定义信息怎么塞进去、甚至后续密钥轮换怎么设计。这篇文章不打算复述官方文档而是围绕 Token 生成策略的选择、核心生成链路拆解以及自定义 Token 的完整实战来写把我实际踩过的坑和验证过的做法一项一项整理出来。这套方案适合正在使用或准备使用 Spring Authorization Server 的团队尤其是那些从老式 Spring Security OAuth2 组件迁移过来的项目。不管你是刚接触授权服务器的新人还是在做既有系统重构的开发者这里的选型思路、定制器用法和排错姿势都值得直接拿去做参考。1. 选Token策略前先把授权服务器的定位看清楚1.1 授权服务器在整条链路里的位置很多人在做 Spring Security OAuth2 授权服务器时第一步就去看怎么生成 Token结果发现官方文档里 JWT 和 Opaque Token 两种方案都能用反而不知道怎么选。我的建议是先把授权服务器在整个系统里的角色捋清楚。授权服务器负责两件事一是认证用户并颁发令牌二是对外暴露公钥或令牌校验接口。真正消费令牌的是下游资源服务器。所以你的选型本质上不是“授权服务器喜欢什么”而是“资源服务器希望怎么校验”。如果资源服务器只有一两个且都部署在内网Opaque Token 完全可行。资源服务器拿到 Token 后调用授权服务器或共享存储来换取用户信息逻辑简单、可控性强。如果资源服务器数量多、调用链复杂、还要支持无状态网关透传JWT 的自包含特性会省掉大量回调请求。每一个资源服务器都能本地验签不依赖中心节点在线这在性能上的差距非常明显。1.2 Opaque Token 与 JWT 的底层差异Opaque Token 就是一串随机生成的字符串通常是 UUID 或一段加密随机数。它本身不携带任何业务信息授权服务器把 Token 和用户数据的映射关系存到 Redis 或数据库里。资源服务器要验证 Token必须回源查询或者共享同一份存储。JWT 正好相反它是一段经过签名的 JSON 数据分为 Header、Claims、Signature 三部分。Claims 里可以放用户 ID、权限、租户、部门等任何需要业务使用的内容。资源服务器拿到 Token 后只需要用授权服务器公开的 JWK 做签名校验本地就能解析出全部信息。我把两者的关键差异整理成一张表方便做决策时直接对比对比维度Opaque TokenJWT存储依赖需要 Redis 或数据库集中存储无状态不依赖存储资源服务器校验回调授权服务器或共享存储本地验签即可撤销能力删记录立刻生效无状态撤销困难自定义信息可以存但读取时需要回源直接解析 ClaimsToken 体积很小通常三十多个字节较大携带完整 Header 和 Claims适合场景高安全要求、内部多系统微服务、网关透传、无状态化1.3 我的选型决策路径我在实际项目中遇到的场景是授权服务器要统一给多个业务系统发令牌每个业务系统都希望尽量减少回调授权服务器的次数。于是我把 JWT 作为默认方案同时对少数需要强制撤销敏感权限的客户端单独配置 Opaque Token。这里有一个容易忽略的点Spring Authorization Server 允许在客户端维度配置 Token 格式不是全局一刀切。通过 TokenSettings 可以为不同客户端指定 SELF_CONTAINED 或 REFERENCE。也就是说绝大多数客户端走 JWT个别安全敏感客户端走 Opaque两套策略并存不会有冲突。这种组合能同时拿到 JWT 的性能优势和 Opaque 的可撤销能力。选型之后真正要花时间啃的是 JWT 的自定义链路因为 JWT 的灵活性是一把双刃剑。想往 Token 里塞自定义字段可以通过 OAuth2TokenCustomizer 实现但很多人在这一步开始踩坑。2. Token生成链路的关键细节与定制入口2.1 默认Token生成流程拆解Spring Authorization Server 在生成 Token 时真正干活的是 OAuth2TokenGenerator 接口。默认情况下这个接口的实现是 DelegatingOAuth2TokenGenerator它会根据授权类型和客户端配置把工作转发给内部的一组生成器。其中 JwtGenerator 负责生成 JWT 格式的 Access Token 和 OpenID Connect 的 ID TokenOAuth2AccessTokenGenerator 负责生成 Opaque 格式的 Access TokenOAuth2RefreshTokenGenerator 负责生成刷新令牌。判断走哪条路径的关键在于你有没有向容器注册 JWKSource 和 JwtEncoder。如果你按照官方示例配置了 JWKSource默认的 Access Token 就是 JWT如果项目里完全没有这些 BeanToken 生成器会回退到 Opaque 路径。这里的坑在于很多人自定义 Token 的时候只往配置类里加了一段定制器代码却忽略了到底有没有走 JwtGenerator结果改了半天 Claims 一点效果都没有。所以动手之前先在配置类里确认这几样东西是否已经装配好JWKSource 是否注册为 BeanJwtEncoder 是否由 Spring Boot 自动装配客户端 TokenSettings 的 accessTokenFormat 是否是 SELF_CONTAINED2.2 JWK与签名算法最容易被忽略的底层设施JWT 的签名依赖 JWKJSON Web Key。授权服务器签发令牌时用私钥签名资源服务器验证时用公钥验签。这个公钥就是通过 JWK 形式暴露出来的默认地址是/.well-known/jwks.json。很多项目在本地跑得通一部署到测试环境就报签名校验失败十次里有八次是公钥和私钥对不上或者签名算法两边配的不一致。我之前遇到过一次资源服务器手动配置了公钥字符串但授权服务器每次启动都会重新生成一把新密钥重启之后资源服务器上的旧公钥自然验不过。这里我给两个建议。第一开发环境可以临时生成随机密钥生产环境必须从固定配置或密钥库加载保证授权服务器重启后密钥不变化。第二JWK 里显式指定算法不要依赖默认推导。用 Nimbus 库构建 RSAKey 时直接通过 algorithm 方法设置算法能少很多诡异问题。签名算法的选择方面RS256 是生态兼容性最好的选择绝大多数 JWT 库都支持。ES256 性能更好、密钥更短但部分老版本客户端和网关对它的支持不完善。如果团队没有特殊诉求直接选 RS256 最省心。2.3 定制器入口OAuth2TokenCustomizer的正确用法Spring Authorization Server 通过 OAuth2TokenCustomizer 提供自定义入口。这个接口是一个泛型接口针对 JWT 和 Opaque Token 分别是两个不同的上下文类型。JWT 路径下使用的是 OAuth2TokenCustomizer 。上下文里暴露了 JwtClaimsSet.Builder可以往里加自定义 Claims同时暴露了 JwsHeader.Builder可以修改 JWT 头信息。Opaque Token 路径下对应的是 OAuth2TokenCustomizer 操作的是 OAuth2TokenClaimsSet.Builder。我在代码评审的时候经常看到有人把这两个定制器用混以为写了一个 JwtEncodingContext 的定制器Opaque 客户端也会生效。实际上 Spring Authorization Server 在生成不同类型 Token 时只会挑选对应类型的定制器泛型擦除之后如果定义多个同名 Bean还有可能造成自动装配冲突。如果项目同时需要自定义 JWT 和 Opaque Token建议不要依赖隐式装配而是手工构建一个 DelegatingOAuth2TokenGenerator在构造时把两个生成器分别绑定各自的定制器。这样既清晰也避免了 Bean 冲突问题。3. 自定义Token生成全流程实战3.1 工程准备与依赖坐标我用的是 Spring Boot 3.x 加 Spring Authorization Server 1.2 的组合这是目前比较主流且稳定的搭配。首先引入核心依赖Maven 的 pom.xml 里加入以下内容dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-jose/artifactId /dependency如果你的项目还在用旧版 Spring Security OAuth 的自动配置建议先做一次版本基线对齐把依赖替换干净。新老组件混用会导致过滤器链重复注册这是实践中出现频率很高的问题。基础配置里最重要的是 issuer 地址。在 application.yml 中设置发布者 URL授权服务器会用它生成 Discovery 文档和各类端点地址server: port: 9000 spring: application: name: auth-server security: oauth2: authorization-server: issuer-url: http://localhost:9000如果项目需要从配置中心动态读取 issuer也可以在配置类里通过 IssuerSettings 手动指定效果一样。3.2 基础授权服务器配置接着写授权服务器的核心配置类。这个类要定义两个 SecurityFilterChain一个用于授权服务器端点一个用于默认的登录链路Configuration EnableWebSecurity public class AuthorizationServerConfig { Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/oauth2/**, /.well-known/openid-configuration, /.well-known/jwks.json) .authorizeHttpRequests(authorize - authorize.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())); return http.build(); } Bean Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize - authorize.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(gateway-client) .clientSecret({noop}gateway-secret) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8080/login/oauth2/code/gateway) .scope(openid) .scope(message.read) .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofMinutes(30)) .build()) .build(); return new InMemoryRegisteredClientRepository(client); } }RegisteredClient 里的 clientSecret 我用{noop}前缀这是为了本地演示。生产环境务必使用 BCrypt 或更安全的密码编码器直接明文存储是不可接受的。3.3 往Access Token里塞自定义Claims业务上最常见的需求是在 Access Token 里加入用户 ID、租户 ID、角色列表等字段。实现方式就是注册一个 OAuth2TokenCustomizer BeanBean public OAuth2TokenCustomizerJwtEncodingContext jwtCustomizer() { return context - { if (context.getTokenType().getValue().equals(OAuth2TokenType.ACCESS_TOKEN.getValue())) { Authentication principal context.getPrincipal(); JwtClaimsSet.Builder claims context.getClaims(); claims.claim(user_id, principal.getName()); claims.claim(tenant_id, tenant-demo); claims.claim(roles, List.of(admin, operator)); } }; }注意这段代码里的判断条件Access Token 和 ID Token 都会进入定制器。如果不区分 Token 类型你给 ID Token 也塞了一堆业务字段ID Token 的体积会变大而且很多地方会把 ID Token 当作身份凭证来解析容易造成字段串扰。在 Authorization Code 流程中context.getPrincipal() 拿到的 Authentication 通常是 UsernamePasswordAuthenticationToken。如果你需要从数据库查用户扩展信息建议在定制器里注入 UserDetailsService按用户名再做一次查询。不要试图从 principal 的 details 里硬挖字段不同认证方式的 details 结构不一致强行解析迟早出问题。3.4 自定义JWT的HeaderJWT 的 Header 里默认包含 alg 和 kid 两个字段。kid 对应 JWK 的密钥 ID这个字段不要乱改改了之后资源服务器通过 kid 找不到对应公钥验签直接失败。但有些场景确实需要自定义 Header比如往里面塞 Token 类型标识或版本号。通过定制器里的 JwsHeader.Builder 可以完成Bean public OAuth2TokenCustomizerJwtEncodingContext jwtHeaderCustomizer() { return context - { context.getJwsHeader().header(token_style, custom-jwt); context.getJwsHeader().header(token_version, 2); }; }如果两个定制器同时存在且都修改 JWTSpring Authorization Server 会按照类型自动装配并依次应用。为了便于维护我习惯把所有 JWT 相关定制整合到一个 Bean 方法中避免多个定制器之间的顺序隐式依赖。3.5 自定义Opaque Token的另一种路径如果你的某个客户端配置了 Opaque Token 格式自定义 Claims 要走 OAuth2TokenClaimsContext 这条通道Bean public OAuth2TokenCustomizerOAuth2TokenClaimsContext opaqueTokenCustomizer() { return context - { context.getClaims().claim(source_system, ops-platform); context.getClaims().claim(user_level, gold); }; }Opaque Token 本身是一串随机字符串自定义的 Claims 不会直接出现在令牌里而是和令牌一起存储在授权服务器的 OAuth2Authorization 对象中。资源服务器回源校验时这些 Claims 才会被提取出来。如果项目同时注册了 JWT 定制器和 Opaque Token 定制器我建议使用手工构建 TokenGenerator 的方式明确绑定关系避免自动装配时 Bean 冲突Bean public OAuth2TokenGenerator tokenGenerator(JWKSourceSecurityContext jwkSource) { JwtEncoder jwtEncoder new NimbusJwtEncoder(jwkSource); JwtGenerator jwtGenerator new JwtGenerator(jwtEncoder); jwtGenerator.setJwtCustomizer(jwtCustomizer()); OAuth2AccessTokenGenerator accessTokenGenerator new OAuth2AccessTokenGenerator(); accessTokenGenerator.setAccessTokenCustomizer(opaqueTokenCustomizer()); return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator); }这种做法的好处是链路完全透明每个生成器绑定哪个定制器一目了然排障的时候不用靠猜。3.6 资源服务器的校验衔接与配置花在授权服务器上的自定义配置最终还是要落到资源服务器上验证效果。资源服务器的配置有两种常见方式。第一种是使用 issuer-uri让资源服务器启动时自动请求授权服务器的 Discovery 文档拿到 JWK 地址和端点信息spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000第二种是直接指定 jwk-set-urispring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://localhost:9000/.well-known/jwks.json用 issuer-uri 配置更省心但有一个坑如果授权服务器在资源服务器启动时不可用Discovery 请求失败会导致启动报错。jwk-set-uri 则在首次验签时才拉取密钥启动更宽容但要自己保证地址正确。资源服务器的 SecurityFilterChain 可以这样写Configuration EnableWebSecurity public class ResourceServerConfig { Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/api/**) .authorizeHttpRequests(authorize - authorize.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())); return http.build(); } }如果需要在资源服务器读取自定义 Claims可以通过自定义 JwtAuthenticationConverter 实现。例如把 roles 字段直接映射为 Spring Security 的 GrantedAuthorityBean public ConverterJwt, AbstractAuthenticationToken jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter converter new JwtGrantedAuthoritiesConverter(); converter.setAuthoritiesClaimName(roles); converter.setAuthorityPrefix(ROLE_); return new JwtAuthenticationConverter(converter); }4. 常见问题与排查技巧实录4.1 高频问题速查表实际操作中遇到的大部分问题都可以总结到下面这个表格里。我每一条都亲自踩过或者帮别人排查过不是从文档里摘的症状可能原因解决方案授权服务器报 invalid_token资源服务器与授权服务器密钥不一致检查 JWK 地址和公钥是否匹配自定义 Claims 读出来是 null定制器没生效或 Token 类型判断错误确认是否注册 JwtEncodingContext 定制器启动时 Discovery 请求失败issuer-uri 对应的授权服务器未启动换成 jwk-set-uri或保证启动顺序JWT 验签报算法不匹配JWK 和 JwtDecoder 的算法配置不一致在 JWK 中显式设置 algorithmID Token 变大给 ID Token 也塞了大量自定义 Claims定制器里按 Token 类型过滤部分客户端 Token 是 Opaque客户端 TokenSettings 配了 REFERENCE检查 accessTokenFormat 配置重启授权服务器后 Token 全失效每次启动随机生成密钥对改为从配置或密钥库加载固定密钥4.2 签名与密钥相关的疑难杂症签名异常是最难排查的一类问题因为它往往只在跨环境部署时出现。我曾经遇到过一个案例授权服务器返回 JWT资源服务器验签时报算法不匹配。两边的配置看起来都一样最后排查到 JWK 里根本没设置 algorithm 字段Nimbus 库推导出的算法和资源服务器默认期望的算法不一致。修复方式就是给 RSAKey 显式加上 algorithm 属性代码如下private RSAKey generateRsaKey() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(RSA); keyPairGenerator.initialize(2048); KeyPair keyPair keyPairGenerator.generateKeyPair(); RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); return new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .algorithm(JWSAlgorithm.RS256) .build(); }关于密钥轮换我同样踩过坑。因为 JWT 是无状态的密钥轮换时必须保留旧密钥一段时间否则仍在使用旧 Token 的客户端会瞬间全部失效。做法是在 JWKSet 中同时放入新旧两把密钥新的用于签发旧的只用于验签Bean public JWKSourceSecurityContext jwkSource() { RSAKey newKey generateRsaKey(); RSAKey oldKey loadOldRsaKey(); JWKSet jwkSet new JWKSet(Arrays.asList(newKey, oldKey)); return (jwkSelector, securityContext) - jwkSelector.select(jwkSet); }默认情况下 JwtEncoder 会选择 JWKSet 中第一个可用密钥进行签名旧密钥继续保留在集合中供验签使用。密钥正式废弃前要观察日志确认客户端流量已经明显降低再移除旧密钥。4.3 自定义Claims读取不到的原因排查自定义 Claims 读不到是最打击信心的问题辛苦写完定制定器发现资源服务器拿到 JWT 里根本没有这个字段。这类问题通常有三个来源。第一定制器被错误地放在了资源服务器而不是授权服务器。很多人在同一个项目里同时配置授权和资源两种角色结果定制器所依赖的 JWKSource 在资源服务器那边是缺的Spring 容器起不来或者路径匹配不到。第二客户端走的是 Opaque Token 格式但定制器用的是 JwtEncodingContext。Token 类型不匹配定制器根本不会触发。第三自定义 Claims 字段名在资源服务器端被安全过滤掉了。JWT 里有些字段名比较特殊例如包含特殊字符或者与标准规范冲突解析时被忽略。推荐的做法是使用自定义前缀比如app_user_id、app_tenant_id与标准字段明显区隔。排查时我一般会在授权服务器上加日志先确认定制器到底有没有执行再拿 JWT 解码看内容不走后端也能判断问题出在哪一段。4.4 调试期必装的排查利器调试授权服务器相关的 Token 问题我会固定用这几套工具和手段一是打开 Spring Security 的调试日志在 application.yml 里加配置logging: level: org.springframework.security: DEBUG org.springframework.security.oauth2.server.authorization: DEBUG二是把授权服务器生成的 JWT 复制到 JWT 解码网站去查看 Header 和 Claims一眼就能看出来签名算法、失效时间、自定义字段是否符合预期。这里注意泄露风险生产环境的 Token 不要随意粘到外部网站但本地开发完全没问题。三是直接通过 curl 模拟客户端走一遍授权码或客户端凭证流程验证令牌的生成链路curl -u gateway-client:gateway-secret \ -d grant_typeclient_credentialsscopemessage.read \ http://localhost:9000/oauth2/token拿到返回的 access_token 后再把它作为 Bearer Token 请求资源服务器接口验证整个链路curl -H Authorization: Bearer access_token \ http://localhost:8080/api/demo这套验证方法能把问题快速定位到授权服务器、Token 本身、还是资源服务器解析这一环。收尾关于 Token 生成策略我个人在实际操作中最大的体会是不要追求技术上的炫技而要尊重系统的真实约束。JWT 适合大多数无状态场景但如果你所在的系统有很强的令牌撤销需求请大胆地把关键客户端切成 Opaque Token。Spring Authorization Server 对两种格式的支持都是完整的不存在高低之分。最后再分享一个小技巧。在自定义 Claims 时给所有自定义字段加上明确的项目前缀比如app_user_id而不是user_id。这样既避免和 OAuth2 标准字段冲突也能让前后端同事一眼看懂字段来源。踩过几次坑之后我才发现这些看起来无关紧要的命名规范才是长期维护一套授权体系最值钱的东西。