Spring Boot 4 迁移实战:Spring Security 7 服务端 DPoP 与 Token Exchange 落地
概述
随着 OAuth 2.1 成为行业推荐标准,Spring Security 在 7.x 正式提供两大核心能力:
- DPoP(RFC 9449):把访问令牌绑定到客户端持有的非对称密钥,防止令牌泄露后被滥用。
- Token Exchange(RFC 8693):标准化的服务间委托(impersonation / delegation)链路,避免自行拼装凭证。
本文基于 Spring Security 7.1.1(对应 Spring Boot 4.1)展开,涵盖协议细节、Spring 实现细节、以及从 1.5.x 迁移至 7.x 的实战步骤。
适合读者:国内正在升级到 Spring Boot 4 的团队、需要实现零信任 API Gateway、或已经在使用 Spring Authorization Server 的安全架构师。
整合:Spring Authorization Server 并入 Spring Security 7
2025‑09‑11,Spring 宣布自 2020 年独立、2022‑11 GA 的 Spring Authorization Server 并入 Spring Security 7.0;1.5.x 是最后一个在独立项目内继续开发的分支。当前坐标为 org.springframework.security:spring-security-oauth2-authorization-server:7.1.1,groupId / artifactId 均未变化,只有版本号从 1.5.x 走到 7.x。Spring Security 7.1.0 发布于 2026‑06(配 Spring Boot 4.1),7.1.1 发布于 2026‑08。

图 1 展示这次整合:独立的 Spring Authorization Server(1.5.x,最后一代)折进 Spring Security 7.x,同一套 Maven 坐标下同时提供 DPoP 与 Token Exchange。
DPoP 工作原理
什么是 DPoP?
DPoP(Demonstrating Proof of Possession)是 RFC 9449 定义的机制:客户端在每次请求时携带一个短生命期的 proof JWT,该 JWT 使用客户端自己的非对称私钥签名。授权服务器校验 proof 后,将访问令牌与证明中的公钥指纹(cnf.jkt)绑定。资源服务器在验证令牌时,同时检查随令牌附带的 proof 是否与绑定的公钥匹配。
核心价值:即使令牌泄露,攻击者没有对应私钥仍无法使用——实现“发钥即有”的密钥拥有证明。
Proof 结构(RFC 9449 §4.2)
typ: dpop+jwtjwk:客户端公钥htm、htu、iat、jti:绑定请求方法、URI、时间戳、唯一 ID- 当携带访问令牌时,还携带该令牌的 SHA‑256 哈希(
ath)

图 2 是服务端 DPoP 闭环:授权服务器用 cnf.jkt 把令牌绑定到客户端公钥,资源服务器在没有匹配 proof 时直接拒绝该令牌。
Spring Security 对 DPoP 的支持
- 授权服务器:公共客户端的 refresh token 自 1.5.0‑RC1(2025‑04) 起由 Spring 自动与该密钥绑定,并沿用到 7.x,没有开关可关;机密客户端仍依赖原有的 client 绑定。
- 资源服务器:DPoP 绑定的访问令牌校验在 6.5.0(2025‑05)GA;7.0 在 classpath 出现 DPoP 类时自动生效,7.1 起必须显式调用
.dPoP()(依赖 Spring Boot 默认资源服务器自动配置的应用同样从未调用过该钩子),否则会静默停止校验。
关键实现点:
| 步骤 | Spring 组件 | 作用 |
|---|---|---|
| Proof 校验 | DPoPProofVerifier + DPoPProofJwtDecoderFactory |
验证 JWT 签名、typ、算法、htm/htu 匹配 |
| Replay 防护 | DPoPProofReplayValidator(默认 10 万条目 InMemoryCache) |
防止 proof 重放 |
| Token 绑定 | 自动在访问令牌中加入 cnf.jkt |
与 proof 公钥指纹对应 |
| 资源服务器 | .dPoP(Customizer.withDefaults()) |
启用 DPoP 验证(7.1+ 必显式调用) |
注意:7.0.x / 7.1.0 的 replay‑cache 是
DPoPProofJwtDecoderFactory内写死的 1 000 条内存缓存,无法替换;自 6.5.12 / 7.1.1 起改为可插拔的DPoPProofReplayValidator,默认自带 10 万条目(按 key 设上限)的 InMemoryCache,但其 Javadoc 明确写着”仅适用于单节点”,集群部署需要自行提供 Redis / Hazelcast 等分布式缓存。
Spring 7.1.1 仍缺的一环:nonce 挑战
RFC 9449 §8 允许授权服务器在 proof 中要求一个由服务端生成的 nonce,用于阻止客户端预铸 proof;§9 给资源服务器提供了对应的校验机制。但截至 7.1.1,Spring 两端都没有实现:不下发 DPoP-Nonce 响应头、不处理 use_dpop_nonce 错误(授权服务器返回 400、资源服务器返回 401),也不校验 proof 中可选的 nonce claim。RFC 中它是 SHOULD 而非 MUST,这大概也是它一直没被实现的原因——但如果你的威胁模型需要它,就得自己实现整套机制:签发 nonce、返回挑战响应、存储已签发值、校验 claim。
落地提示:原文明确指出,截至撰文时 Spring Security 的 issue tracker 中没有相关的开放特性请求,需要 nonce 的身份提供方应当按”自建”来规划,而不是等下一个版本。
Token Exchange(RFC 8693)
为什么需要 Token Exchange?
在微服务或 API‑Gateway 场景下,服务 A 需要以 用户 身份调用 服务 B,但又不想直接把用户的令牌转发(防止令牌泄露给下游)。传统做法是自行拼接新凭证或使用自定义协议,缺少统一标准。
RFC 8693 引入两种语义:
| 语义 | 说明 | 实现方式 |
|---|---|---|
| Impersonation | 只保留 sub(被代理者) |
只提交 subject_token |
| Delegation | 同时保留 sub + act(委托者) |
同时提交 subject_token 与 actor_token |
Spring Security 通过 OAuth2TokenExchangeAuthenticationProvider 处理,生成的令牌自动携带 act( delegation)或仅保留 sub(impersonation)。
示例:Edge‑Service 委托调用 Orders 服务
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ...
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ... # 用户的访问令牌
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=eyJ... # Edge‑Service 的令牌
&actor_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://orders.internal
&scope=orders:read
返回的令牌示例:
{
"sub": "user-123",
"aud": "https://orders.internal",
"scope": "orders:read",
"act": {
"sub": "edge-service",
"act": { "sub": "gateway-service" } # 多跳委托链
}
}

图 3 展示组合模式:客户端先持有 DPoP‑bound token,Edge‑Service 再对该 token 做 exchange,授权服务器签发收窄后的新令牌(scope 收缩、audience 切换、actor 记入 act claim),并通过 cnf.jkt 把新令牌重新绑定到 Edge‑Service 的密钥上——每一跳都是 sender‑constrained。
Spring 对 Token Exchange 的默认行为
| 项目 | 默认值 | 可自定义 |
|---|---|---|
| 授权服务器 | 默认关闭(需在 RegisteredClient 中开启 TOKEN_EXCHANGE) |
通过 authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) |
| 作用域收窄 | 保留原令牌的全部 scope | 通过 OAuth2TokenCustomizer<JwtEncodingContext> 裁剪 |
| 作用域收窄 | 默认不收窄(框架不会自动裁剪 scope 或切换 audience) | 通过 OAuth2TokenCustomizer<JwtEncodingContext> 自行裁剪 |
| 审计(act) | 自动生成 | 由 actor_token 派生,不建议手工拼装 |
> 安全提醒:Token Exchange 必须 收窄 而非放宽范围,否则会产生特权提升漏洞。原文强调:Spring 只提供 grant 与 customizer 钩子,不会阻止你写出放大 scope 的实现——OAuth2TokenCustomizer 应当作为安全关键代码补上单测。 |
迁移实战:从 1.5.x 到 7.x
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 1. 坐标升级 | spring-security-oauth2-authorization-server 从 1.5.x → 7.1.x |
保持 groupId、artifactId 不变 |
| 2. DSL 迁移 | and() 风格 → lambda DSL |
兼容旧链路的 HttpSecurity 需要改写 |
| 1b. Starter 更名 | spring-boot-starter-oauth2-authorization-server → spring-boot-starter-security-oauth2-authorization-server |
旧 starter 标记废弃,两个坐标在 4.1.1 前仍同时发布 |
| 3. JWT Decoder | 核对 validateTypes(false) 与 JwtTypeValidator 默认值 |
7.0 起默认开启类型校验;at+jwt(RFC 9068)等非 JWT 类型的令牌需自行注册校验器 |
| 4. Jackson 模块 | SecurityJackson2Modules → SecurityJacksonModules |
持久化授权状态需升级 |
| 5. 开启 DPoP | 在资源服务器显式调用 .dPoP()(7.1+) |
否则 7.1 默认不校验 |
| 6. Token Exchange | 在 RegisteredClient 中加入 TOKEN_EXCHANGE |
按业务需求开启 |
| 7. 认证方案 | DPoP 绑定的令牌走 DPoP 认证方案,不能按 Bearer 发送 | 这是常见且错误的捷径,会被资源服务器直接拒绝 |
迁移成本:原文口径是基线迁移约 1 个 Sprint(且是在启用任何新 OAuth 2.1 特性之前);版本落后较多的团队应把”基线迁移”和”特性接入”拆成两件事来做。
关键数据回顾
- replay‑cache:自 6.5.12 / 7.1.1 起为可插拔的
DPoPProofReplayValidator,默认 InMemoryCache 10 万条目(按 key 设上限,仅单节点有效);此前 7.0.x / 7.1.0 为 1 000 条硬编码; - 迁移耗时:基线迁移约 1 个 Sprint(原文本就是”约一个 sprint”,且不含新特性启用);
.dPoP():在 Spring Security 7.1 必须显式调用,否则 DPoP 验证静默失效;- nonce 缺口:RFC 9449 §8 的 nonce 挑战在 7.1.1 中完全未实现(不下发
DPoP-Nonce头、不处理use_dpop_nonce、不校验 nonce claim),需要时只能自建; - Token Exchange:默认 impersonation, delegation 需提供
actor_token。
参考链接
- RFC 9449 – DPoP
- RFC 8693 – OAuth 2.0 Token Exchange
- Spring Security 7.1 官方文档 – DPoP 令牌
- Spring Authorization Server 并入 Spring Security 7.0(官方公告,2025‑09‑11)
- Spring Security 7 迁移指南(官方文档)
- InfoQ 原文:Spring Security 7 服务端 DPoP 与 Token Exchange
国内落地提示:在迁移至 Spring Boot 4 与 Spring Security 7 时,建议先在 UAT 环境开启 双跑(同时支持 Bearer 与 DPoP),待全部客户端迁移后再下线旧模式。
