Key Takeaways
- Spring Authorization Server now lives inside Spring Security 7 under the same Maven coordinates, so moving off 1.5.x is a version bump, rather than an artifact swap.
- Demonstrating Proof of Possession (DPoP) (RFC 9449) already worked end-to-end on Spring Security 6.5 with Spring Authorization Server 1.5. The real news on the 7.1 line is that resource-server verification is no longer automatic, so an app that relied on the old auto-applied behavior silently stops enforcing DPoP after upgrading until it adds the new
.dPoP()call. - Spring binds the JWK thumbprint via
cnf.jktand the refresh token for public clients on its own, leaving two real decisions to the identity provider: the nonce policy and whether thejtireplay cache, which defaults to an in-memory, single-node implementation, needs to be swapped for a distributed one across a cluster. - RFC 8693 Token Exchange turns the bespoke “service-account swap” many identity providers (IdPs) still carry into a standard grant, with
subject_tokenandactor_tokensemantics and an `act` claim that records the delegation chain. - The two features are more useful together than apart, because a DPoP-bound subject token exchanged for a narrowed, audience-restricted downstream token is the pattern service-to-service and agentic architectures keep reaching for, but the combined pattern is only as safe as the exchange policy and token customizer you write.
Your in-house identity provider has run on Spring Authorization Server 1.x for two years. The upgrade to Spring Boot 4 looks routine on paper. You bump the Bill of Materials (BOM), fix the deprecations, and ship it. Then you open the migration guide and find that the authorization server you depend on is no longer a separate project. It moved into Spring Security 7.0 while you were on the 1.x line. On the current 7.1 line, paired with Spring Boot 4.1, the two OAuth 2.1 features you have been deferring, sender-constrained tokens and a real delegation grant, are sitting in the same module, Spring General availability (GA) release, waiting to be switched on.
This is the part of the Spring Boot 4 migration that the migration guides skip. They cover Jackson 3, the lambda DSL, and the package relocations, then stop at OAuth 2.0. The auth-layer payoff is DPoP (RFC 9449) and Token Exchange (RFC 8693), both are production-ready in official components for the first time, which is where the upgrade actually earns its keep for anyone running their own IdP. This article is the server-side walk-through, covering what has been consolidated, what changes when you turn DPoP on, what an RFC 8693 exchange looks like end-to-end, and why the two features are worth running together. It assumes you already run an OAuth 2.0 / OIDC server and know the protocol. It does not re-explain OAuth from scratch.
What Spring Boot 4 Consolidated
Spring’s November 2025 release train shipped in three steps: Spring Framework 7.0 on November 13, Spring Security 7.0 on November 17, and Spring Boot 4.0 on November 20. The release notes lead with modularization, JSpecify null-safety, API versioning, and the Jackson 3 baseline. The OAuth server story sits underneath these modules The changes had been announced two months earlier.
On September 11, 2025, the Spring team announced that Spring Authorization Server, a standalone project since 2020 and GA since November 2022, was moving into Spring Security 7.0. The standalone 1.5.x branch is the last generation with new development happening inside Spring Security. Spring Security shipped 7.1.0 in June 2026, paired with Spring Boot 4.1, then 7.1.1 in August 2026, so the current coordinates are org.springframework.security:spring-security-oauth2-authorization-server:7.1.1. The consolidation itself happened at 7.0. The examples here target 7.1.1 specifically, because that is where both version-specific details this article covers land: the resource-server .dPoP() opt-in (new in 7.1.0) and the pluggable jti replay cache, which were new in 7.1.1 Each is called out where it appears. For a team that has been treating “the authorization server” as a separate dependency with its own release cadence, the consolidation is the headline. There is now one security module, one version, and one set of release notes to track.
There is a subtlety here worth getting right, because it is the single most common thing people get wrong about this migration. The Maven coordinates did not change. The standalone project was already published under org.springframework.security:spring-security-oauth2-authorization-server and the merged module in Spring Security 7 uses the same groupId and artifactId. Only the version moved from the 1.5.x line onto the Spring Security version line (7.0.x, now 7.1.x). If you read somewhere that you need to rename a spring-authorization-server artifact, that artifact never existed. One genuine rename does apply if you pull the authorization server in through the Boot starter rather than the library directly. In Boot 4, the starter gained a security- prefix, so spring-boot-starter-oauth2-authorization-server is deprecated in favor of spring-boot-starter-security-oauth2-authorization-server with both still published through 4.1.1. Beyond that, the migration is a version bump plus the same package relocations. Jackson moves every Spring Security 7 consumer absorbs. It is not a coordinate swap.
Xml
org.springframework.security
spring-security-oauth2-authorization-server
1.5.7
org.springframework.security
spring-security-oauth2-authorization-server
7.1.1
By moving to the 7.x line you inherit a module where DPoP issuance, refresh-token DPoP binding, and Token Exchange already coexist with the resource-server DPoP support that reached GA in Spring Security 6.5. Before this consolidation, getting all of those in one place required pinning a specific standalone authorization server version to a specific Spring Security version and hoping the matrix lined up. Now it is one version number.

Figure 1. The Spring Boot 4 consolidation. (Source: created by author.)
As shown in Figure 1, the standalone Spring Authorization Server (1.5.x, last generation) folds into the Spring Security 7.x line under the same Maven coordinates, consolidating DPoP and Token Exchange into a single module.
The Migration Tax, Paid Once
Before any of the new features, Spring Security 7 charges a baseline migration tax that is mostly not OAuth-specific: and() is gone from the HttpSecurity builder, Jackson 3 replaces Jackson 2, and a couple of JSON Web Token (JWT)-decoder defaults change. Spring’s own migration guide and its OAuth 2.0-specific page walk through the mechanics; the concrete steps are items 2 through 4 in the playbook below.
The guide does not spell out two things, because they are specific to running an authorization server rather than any Spring Security app. First, the typ header change is not a loosening of validation. Rather, it is Spring finally wiring in automatically what the 6.5 preparation steps asked you to add by hand: validateTypes(false) turning off Nimbus’s own typ check so you could register JwtTypeValidator.jwt() yourself and in 7.0 both halves of that dance happen for you. The actual audit point is narrower than “will my tokens still validate?”. JwtTypeValidator.jwt() requires typ to be JWT or absent, so any token your server issues or accepts with a different typ, such as the RFC 9068 at+jwt access-token type, now fails validation by default unless you register a validator that allows it. Second, if you persist OAuth2Authorization objects to a database with Spring’s Jackson modules, that registration has to move from the Jackson 2 SecurityJackson2Modules to the Jackson 3 SecurityJacksonModules in the new org.springframework.security.jackson package, or deserialization of previously persisted authorization state will break.
A team that is already up to date with Spring Security 6.5 deprecations can plan the baseline migration as roughly a one-sprint effort before touching any new OAuth 2.1 features. A team several versions behind should treat the baseline migration and the feature adoption as two separate efforts.
DPoP, End-to-End
DPoP (RFC 9449) constrains an access token to a key the client holds. The client generates an asymmetric key pair and, on each request, signs a short-lived proof JWT. The authorization server validates that proof at the token endpoint and binds the issued access token to the proof’s public key. The resource server then refuses to honor the token unless it arrives with a fresh proof from the same key. A stolen token is inert without the private key. That is the whole point and it is the subject of this article’s browser-side companion, which addresses the hard problem of where a browser can safely keep that key. Here the concern is the other end of the wire, namely what the server does when the proof arrives.
The proof itself is specified in RFC 9449 §4.2. The header carries typ: dpop+jwt, an asymmetric alg, and the client’s public key inline as a jwk. The payload binds the proof to the request with htm (HTTP method), htu (target URI, query and fragment stripped), iat, and a unique jti. When the proof accompanies an access token to a resource server, it also carries the base64url SHA-256 hash of that token.

Figure 2. The DPoP loop, server side. The authorization server binds the token to the client’s key, and the resource server refuses it without a matching proof. (Source: created by author.)
Issuance: Two Decisions the Defaults Leave to You
On the authorization-server side, Spring wires the proof-verification machinery for you. DPoPProofVerifier and the supporting DPoPProofJwtDecoderFactory perform the RFC 9449 §4.3 checks: a well-formed JWT, a typ of dpop+jwt, a non-none asymmetric alg, a signature that verifies against the embedded jwk, and htm/htu matching the token request. Two things that look like decisions aren’t. What is actually left to you is building the nonce challenge yourself (if you want it at all) and the reach of the replay cache.
The cnf.jkt binding is handled for you. The access token must carry a cnf claim whose jkt member is the base64url-encoded JWK SHA-256 Thumbprint (RFC 7638) of the proof key. Spring populates this key automatically when DPoP is in play; the only thing to watch for is a token customizer that overwrites cnf by accident. The resource server later compares against the thumbprint, not the public key and not a certificate hash.
JSON
{
"sub": "service-a",
"aud": "
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
}
}
First, consider the nonce challenge, which Spring does not implement at all. RFC 9449 §8 lets the authorization server demand a server-chosen nonce inside the proof, which stops a client from pre-minting proofs; the resource server has the parallel mechanism in §9. When a server requires a nonce the client did not supply, it answers with error: use_dpop_nonce and a DPoP-Nonce response header, 400 at the authorization server, and 401 at the resource server. As of Spring Security 7.1.1, none of that exists in Spring’s DPoP code on either side: no issuance of a DPoP-Nonce header, no handling of use_dpop_nonce, and no validator checking the proof’s optional nonce claim. The nonce challenge is a SHOULD in the RFC, not a MUST, which is presumably why it has shipped without one, but if your threat model needs it, you are building the entire mechanism yourself: issuing nonces, returning the challenge response, storing what you issued, and validating the claim against it.
Also Handled for You: Refresh-Token Binding for Public Clients
RFC 9449 §5 requires that when a public client presents a DPoP proof at the token endpoint, the refresh token it receives is bound to that key, and later refreshes must prove the same key. Spring Authorization Server added this key automatically in the 1.5.0-RC1 milestone (April 2025) and carried it into the 7.x line; there is no switch to flip. Confidential clients lean on their existing client-credential binding instead, so this approach matters most for single-page apps (SPAs) and native apps.
Second, there is the replay cache. RFC 9449 §11.1 requires rejecting a proof whose jti has already been seen, within an acceptable time window. On Spring Security 7.0.x and 7.1.0, that check was hard-coded: a fixed, thousand-entry in-memory cache buried inside DPoPProofJwtDecoderFactory, with no way to swap it out. Since Spring Security 6.5.12 / 7.1.1, it is the pluggable DPoPProofReplayValidator, wired by default with its own InMemoryCache (one hundred thousand entries, capped per key). That default implementation’s own Javadoc says plainly that it “only works in a single-node setup” and names Redis and Hazelcast as the kind of distributed Cache a clustered deployment needs to supply instead.
Verification Is Now Stock at the Resource Server
The resource-server side is where Spring 7 removes the most custom code. DPoP-bound access-token verification arrived through the Spring Security 6.5.0 milestones and went GA in 6.5.0 (May 2025). One version detail matters here. In Spring Security 7.0 that support was applied automatically whenever the DPoP classes were on the classpath, with no DSL hook. Spring Security 7.1 added an explicit dPoP(Customizer) method to the resource-server DSL (@since 7.1), making support opt-in and customizable; in 7.1, you call .dPoP() to enable it. This is a genuine behavioral change on upgrade, not just a new option: an application that relied on the auto-applied verification in 6.5 or 7.0, including one built entirely on Spring Boot’s default resource-server autoconfiguration, which never called the new hook either, silently stops enforcing DPoP the moment it moves to 7.1 until .dPoP() is added explicitly. A DPoP-bound token arrives under the DPoP authentication scheme, not Bearer. Sending it as Bearer is a common and incorrect shortcut. Spring’s DPoPAuthenticationProvider does the work.
One naming trap is worth flagging, because it will bite during code review. The resource-server provider is DPoPAuthenticationProvider, with no OAuth2 prefix, unlike OAuth2TokenExchangeAuthenticationProvider in the next section. The converter and provider also live in different packages. DPoPAuthenticationConverter sits in ...resource.web.authentication, while DPoPAuthenticationProvider and DPoPAuthenticationToken sit in ...resource.authentication, without the web.
Java
// Resource server: accept DPoP-bound access tokens.
@Bean
public SecurityFilterChain resourceServer(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()) // validates the access token
.dPoP(Customizer.withDefaults()) // validates the proof: signature, htm/htu, ath, cnf.jkt
);
return http.build();
}
With .dPoP() configured, the resource server verifies the proof signature, confirms htm/htu against the incoming request, checks that the proof’s path equals the hash of the presented token, and confirms the proof key’s thumbprint equals the token’s cnf.jkt. Before Spring Security 6.5 this was hand-written filter code. Now it is one line in the DSL.
Token Exchange Without the Bespoke Plumbing
Most in-house IdPs carry some version of “service-account swap”. Service A needs to call service B on behalf of a user, so it reaches for a second set of credentials, mints a fresh token through a side channel, or passes the user’s own token downstream and hopes the audience checks are lenient. RFC 8693 replaces that approach with a standard grant.
Token Exchange landed in Spring Authorization Server 1.3.0-M3 (March 2024) and is in the 7.x module. The grant type is urn:ietf:params:oauth:grant-type:token-exchange. The request includes a subject_token, the token representing the party on whose behalf the call is made, and an optional actor_token representing the party acting, along with the audience, resource, and scope the caller wants on the way out.
Http
POST /oauth2/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ... # the edge service's proof, so the issued token re-binds to its key
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJ... # the incoming user token, on whose behalf the call is made
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=eyJ... # the edge service's own token; this is what produces the act claim
&actor_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=
&scope=orders:read
The exchange matters most for the two semantics that RFC 8693 §1.1 distinguishes. Impersonation issues a token that appears to be the subject and the actor disappears. Delegation preserves both identities, the issued token carries the subject and an act (actor) claim identifying who is acting. Which one you get is decided by a single parameter. Send only a subject_token and Spring’s OAuth2TokenExchangeAuthenticationProvider issues an impersonation token with no act claim. Send an actor_token alongside it and the provider builds a delegation token, deriving the act claim from the actor_token rather than from the authenticated client. For anything you will need to audit later and for service-to-service calls, delegation is exactly what you want.
RFC 8693 §4.1 represents a multi-hop chain by nesting act claims, with the outermost being the current actor and each nested one a prior actor. The nested entries come from the subject token. If the gateway’s earlier exchange already left an act claim on the token the edge service now presents as its subject_token, Spring carries that prior actor through as the nested entry. So in this example, edge-service, the actor token’s owner, becomes the current actor, and gateway-service is nested beneath it as the earlier one.
JSON
{
"sub": "user-123",
"aud": "
"scope": "orders:read",
"act": {
"sub": "edge-service",
"act": {
"sub": "gateway-service"
}
}
}
On the Spring side, the grant is handled by the OAuth2TokenExchangeAuthenticationProvider, which is registered through the authorization-server configurer. The grant is not enabled by default per client. The calling client’s RegisteredClient must declare it, or the provider rejects the request with unsupported_grant_type.
Java
RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("edge-service")
// ... client authentication, scopes ...
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
.build();
The act claim, itself, is the framework’s job, built from the actor token as described above, so you do not assemble it by hand. What is left to you is narrowing the issued token by dropping scopes or switching the audience, which is the job of an OAuth2TokenCustomizer bean, the same hook you would use for any claim shaping.
Java
@Bean
public OAuth2TokenCustomizer tokenCustomizer() {
return context -> {
if (AuthorizationGrantType.TOKEN_EXCHANGE.equals(
context.getAuthorizationGrantType())) {
// Narrow the audience and trim scopes on the exchanged token.
context.getClaims().audience(List.of("
context.getClaims().claim("scope", "orders:read");
}
};
}
One principle is worth holding on to. The exchange is where you reduce authority, so never widen it. A token-exchange endpoint that can be leveraged to access a broader scope than the subject token holds is a privilege-escalation bug, not a feature.
The Pattern That Needs Both DPoP and Token Exchange
The two features are each useful alone. Composed, they cover a problem neither closes by itself: propagating constrained authority across a chain of services without handing every hop a bearer token it could replay.
Picture an edge service that receives a DPoP-bound access token from a client. It needs to call an orders service on the user’s behalf, but it should not forward the user’s token wholesale. That token’s audience is the edge and forwarding a bearer copy gives the orders service something it can, in turn, replay anywhere. Instead, the edge service performs a token exchange. It presents the user’s token as the subject_token, its own token as the actor_token, and a DPoP proof on the exchange request, then asks for a token scoped to orders:read, audience The authorization server issues a narrowed token, records the edge service in the act claim from that actor token, and, because the edge sent a proof, binds the new token to the edge service’s own key through cnf.jkt.

Figure 3. DPoP-bound token exchanged for a narrowed, re-bound downstream token. Scope shrinks, audience switches, the actor is recorded, and each hop is sender-constrained. (Source: created by author.)
The result is concrete. The orders service receives a token it can verify is sender-constrained to the caller it is actually talking to, scoped to exactly what that call needs, with an audit trail of who delegated to whom. No hop holds a replayable credential for a service it does not call. This is the shape that modern service meshes and agentic systems keep reinventing badly with ad hoc header passing. RFC 8693 plus RFC 9449 are the standards-track versions and Spring Security 7 is the first official Spring surface where both halves ship together.
There is a real caveat. The narrowing is yours to enforce. Spring gives you the grant and the customizer hook, but it does not stop you from writing an exchange that widens scope or skips the audience switch. The security of the pattern lives in the OAuth2TokenCustomizer you write. That code deserves its own tests.
A Migration Playbook
Two starting points cover most teams. The first is Greenfield on Spring Boot 4.1. A new IdP starts on the 7.x module with DPoP and Token Exchange available from day one. The work is configuration, not migration: register clients, decide your nonce policy, write the narrowing customizer, and turn on .dPoP() at every resource server. Treat the OAuth2TokenCustomizer as security-critical code from the first commit.
The second starting point is an existing standalone Authorization Server 1.5.x. This is a version bump on coordinates you already use, not a re-platforming. The sequence:
- Move to the Spring Boot 4.1 / Spring Security 7.1 BOM. Bump spring-security-oauth2-authorization-server from 1.5.x to 7.1.x, same groupId and artifactId.
- Convert any remaining
and()-style filter chains to the lambda DSL. - Audit
JwtDecoderconfiguration for validateTypes(false) and confirm the newJwtTypeValidatordefaults accept your tokens. - Update Jackson module registration where you persist authorization state, so
SecurityJackson2ModulesbecomesSecurityJacksonModules. - Only then, as a separate change, enable DPoP for new client registrations and stand up the token-exchange grant, after the baseline migration is green.
The production rollout has its own decisions. The decisions that turn a feature flag into a deployment. Keep the issuer URL stable across the cutover. Advertise DPoP and the token-exchange grant in the authorization-server metadata so clients can discover them. Roll out resource-server .dPoP() support before you start issuing DPoP-bound tokens, so the verifier is ready when the first bound token arrives. Plan a dual-stack window in which bearer and DPoP-bound tokens coexist as clients migrate. Enable DPoP per client registration rather than globally, so the existing fleet keeps working while new registrations opt in.
The metadata is what lets clients discover all of this functionality. The authorization server’s /.well-known/oauth-authorization-server document should advertise both capabilities: the DPoP signing algorithms it accepts and the token-exchange grant in its grant-type list.
JSON
{
"issuer": "
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"dpop_signing_alg_values_supported": ["ES256", "RS256"]
}
What Comes Next
Watch the nonce gap specifically. The RFC marks it a SHOULD, not a MUST, and there is no open feature request for it in Spring Security’s issue tracker as of this writing, so an identity provider that wants it is building it alone and should plan for that rather than wait on a future release.
The more durable trend sits a level above this one release. Multi-hop service calls and increasingly chains of autonomous agents acting across trust boundaries need both proof of key possession and an audited, narrowing delegation chain, not just a bearer token and a scope check. RFC 8693’s nested `act` claim already models that chain. As that architecture spreads, the pairing this article covers, DPoP binding plus token exchange, looks less like a Spring Security 7 feature pairing and more like a default expectation for identity providers generally.
References
- RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP), IETF, September 2023.
- RFC 8693, OAuth 2.0 Token Exchange, IETF, January 2020.
- RFC 7638, JSON Web Key (JWK) Thumbprint, IETF, September 2015.
- “Spring Authorization Server is moving to Spring Security”, Spring blog, September 11, 2025.
- “Spring Framework 7.0 General Availability”, Spring blog, November 13, 2025.
- “Spring Boot 4.0.0 available now”, Spring blog, November 20, 2025.
- Spring Security 7.0.0 release, GitHub, November 17, 2025.
- Spring Security Reference, OAuth 2.0 DPoP-bound Access Tokens.
- Spring Security issue #16940 / PR #17202, “Add support dpop customization” (dPoP(Customizer), @since 7.1).
- “The DPoP Storage Paradox: Why Browser-Based Proof-of-Possession Remains an Unsolved Problem”, InfoQ, April 2026 (companion article).
(All URLs accessed October 2026.)