Spring Boot Flow Recipes
|
This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against. OAuth 2.1 is still an Internet-Draft ( This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Spring Boot Integration: Overview named the three roles and the "already documented here" table. This page picks up exactly where that table stops: one minimal recipe per grant, only for the grants the SpringBoot reference section does not already work out end to end. Each recipe names the RFC it implements, links back to that grant’s own protocol page, and shows client-side config, server-side config (where a Spring Authorization Server is involved) and the resulting HTTP exchange. Every class and property name below was confirmed against the live Spring Security and Spring Authorization Server reference documentation before being written down here; where the reference docs are silent on something, that is stated plainly rather than filled in from memory.
authorization_code + PKCE as a client
RFC 6749 §4.1 + RFC 7636 — see Authorization Code and PKCE for the full protocol treatment. This grant already has two complete Spring worked examples elsewhere in the repository, so there is no third one here:
-
When your own service is the authorization server, "A client round-trip" on Authorization Server & Social Login walks the whole exchange with
curl, including generating the PKCE pair by hand. -
When your service is a client of someone else’s authorization server (social/federated sign-in), "Social login (Google, Microsoft, Apple)" on the same page shows
oauth2Login()end to end, including the sequence diagram of the browser round-trip.
|
PKCE for confidential clients. RFC 7636 was written for public clients that cannot hold a secret, but the
in-progress OAuth 2.1 consolidation ( |
client_credentials with OAuth2AuthorizedClientManager and a RestClient interceptor
RFC 6749 §4.4 — see Client Credentials and Client Authentication for the full grant and the client-authentication-method comparison. This is service-to-service: there is no user, no browser redirect and (per that page) no refresh token, so the whole recipe is a registration plus a manager plus an interceptor.
Client-side config — register the partner API as an OAuth2 client with the client_credentials grant:
spring:
security:
oauth2:
client:
registration:
partner-api:
client-id: ${PARTNER_CLIENT_ID}
client-secret: ${PARTNER_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: [orders.read, orders.write]
provider:
partner-api:
token-uri: https://partner.example.com/oauth2/token
@Bean
OAuth2AuthorizedClientManager clientCredentialsAuthorizedClientManager(
ClientRegistrationRepository clientRegistrations,
OAuth2AuthorizedClientRepository authorizedClients) {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
DefaultOAuth2AuthorizedClientManager manager =
new DefaultOAuth2AuthorizedClientManager(clientRegistrations, authorizedClients);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
@Bean
RestClient partnerApiClient(OAuth2AuthorizedClientManager manager) {
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "partner-api"); // fixed registration, no per-user principal
return RestClient.builder()
.baseUrl("https://partner.example.com")
.requestInterceptor(interceptor)
.build();
}
Every call through partnerApiClient runs the interceptor first; the interceptor asks the manager for an
OAuth2AuthorizedClient, which requests (and caches) a token the first time and reuses it until it is close to
expiry, at which point the manager silently requests a fresh one — there is no user session to redirect and no
refresh token in this grant, so "refreshing" here just means asking the token endpoint again with the same
client credentials.
The resulting exchange, once per token acquisition (not once per API call):
POST /oauth2/token HTTP/1.1
Host: partner.example.com
Authorization: Basic cGFydG5lci1hcGk6...
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=orders.read+orders.write
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "orders.read orders.write"
}
followed by the actual API call carrying that token: GET /orders HTTP/1.1 with
Authorization: Bearer eyJhbGciOiJSUzI1NiIs….
refresh_token and what Spring does on expiry
RFC 6749 §6 — see Access and Refresh Tokens for rotation,
reuse detection and the absolute-vs-idle-expiry distinction at the protocol level. On the client side there is
usually no recipe to write: add .refreshToken() to the same provider builder used above and Spring handles the
rest transparently.
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.build();
What actually happens on expiry: every OAuth2AuthorizedClientManager.authorize(…) call re-checks the stored
the OAuth2AuthorizedClient access token against its expires_at (with a small clock-skew allowance) before
handing it back. If it is expired (or close to it) and a refresh token is present, the refresh-token provider
added by .refreshToken() silently exchanges it at the token endpoint and the manager saves the new
OAuth2AuthorizedClient — including whatever new refresh token the authorization server chose to issue — back
into the OAuth2AuthorizedClientRepository, before your interceptor or filter ever sees a stale token. If the
refresh attempt itself fails (the refresh token was rotated-and-reused, revoked, or has hit its absolute
lifetime), the manager reports the client as no longer authorized: for authorization_code, that means the next
request has to go through the interactive /oauth2/authorization/{registrationId} redirect again; for
client_credentials, which is issued no refresh token at all, "refreshing" is simply requesting a brand-new
token with the same client credentials, which the clientCredentials() provider already does on every expiry.
None of this is configurable from the client side beyond adding or omitting .refreshToken() to the builder — rotation policy, reuse detection and token lifetimes are entirely the authorization server’s decision, covered
on Access and Refresh Tokens.
urn:ietf:params:oauth:grant-type:device_code — both sides
RFC 8628 — see Device Authorization Grant for the polling loop and the cross-device phishing mitigations. Spring supports this grant fully on the authorization-server side; on the client side there is a real gap.
Authorization-server side
Add AuthorizationGrantType.DEVICE_CODE to the allowed grant types of a RegisteredClient; the token endpoint
already accepts urn:ietf:params:oauth:grant-type:device_code alongside authorization_code,
refresh_token and client_credentials (see the "Default endpoints" table on
Authorization Server & Social Login) — no
separate switch is needed beyond registering the grant type on the client:
RegisteredClient tvApp = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("tv-app")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.scope("orders.read")
.build();
The device-authorization and device-verification endpoints (/oauth2/device_authorization,
/oauth2/device_verification by default — see
Authorization Server & Social Login's
endpoint table) are already active on the @Order(1) authorization-server filter chain from that page; nothing
extra is required there.
The wire exchange, an input-constrained device (a TV, a CLI) obtaining a token without a browser of its own:
POST /oauth2/device_authorization HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
client_id=tv-app&scope=orders.read
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/oauth2/device_verification",
"verification_uri_complete": "https://auth.example.com/oauth2/device_verification?user_code=WDJB-MJHT",
"expires_in": 1800,
"interval": 5
}
The device then polls at the given interval:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS&client_id=tv-app
which returns {"error":"authorization_pending"} until a human visits verification_uri_complete and approves
it, then finally an ordinary token response (access_token, refresh_token, expires_in, scope).
Client-side gap
There is no shipped device-code client provider: OAuth2AuthorizedClientProviderBuilder has no
.deviceCode() builder method, and spring-security-oauth2-client ships no
DeviceCodeOAuth2AuthorizedClientProvider. If your service needs to consume the device-code grant as a
client (for example, a CLI tool built on Spring Boot that authenticates its own user this way), you have to
implement your own OAuth2AuthorizedClientProvider — one that posts to /oauth2/device_authorization, drives
the polling loop above, and produces an OAuth2AuthorizedClient — and register it explicitly:
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.provider(new MyDeviceCodeAuthorizedClientProvider()) // hand-rolled; nothing ships in the framework
.refreshToken()
.build();
Only a hand-rolled sample exists in the Spring Authorization Server demo-client repository — there is no supported starter for this half of the flow, so budget the implementation effort accordingly.
urn:ietf:params:oauth:grant-type:token-exchange
RFC 8693 — see
Token Exchange and Assertion Grants for
impersonation vs. delegation and the act claim. Spring Authorization Server accepts this grant at the token
endpoint alongside the others listed above; as a client, use the dedicated provider and response client:
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.provider(new TokenExchangeOAuth2AuthorizedClientProvider())
.build();
TokenExchangeOAuth2AuthorizedClientProvider talks to the token endpoint through a
RestClientTokenExchangeTokenResponseClient. The wire exchange, a service exchanging a caller’s token for one
scoped down to an internal call:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Authorization: Basic b3JkZXItc2VydmljZTo...
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJhbGciOiJSUzI1NiIs...
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://inventory.internal.example.com
&scope=inventory.read
The response is an ordinary token response with issued_token_type echoing back
urn:ietf:params:oauth:token-type:access_token.
urn:ietf:params:oauth:grant-type:jwt-bearer
RFC 7523 (the JWT profile of the RFC 7521 assertion framework) — see
Token Exchange and Assertion Grants. This is a
client-side-only grant in Spring Authorization Server’s own vocabulary: it is not in the list of grants the
authorization server’s token endpoint documents accepting from arbitrary registered clients (that list is
authorization_code, refresh_token, client_credentials, device code and token exchange) — jwt-bearer is
the client presenting an assertion it already holds, most often issued by a different trusted party, to obtain
a token without a fresh interactive login.
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.provider(new JwtBearerOAuth2AuthorizedClientProvider())
.build();
JwtBearerOAuth2AuthorizedClientProvider talks to the token endpoint through a
RestClientJwtBearerTokenResponseClient. Wire exchange:
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=eyJhbGciOiJSUzI1NiIs...
private_key_jwt and the mTLS situation
RFC 7523 (client authentication) — see
Client Credentials and Client
Authentication for the full comparison of client-authentication methods. ClientAuthenticationMethod documents
client_secret_basic, client_secret_post, private_key_jwt, client_secret_jwt and none.
spring:
security:
oauth2:
client:
registration:
partner-api:
client-authentication-method: private_key_jwt
Function<ClientRegistration, JWK> jwkResolver = (clientRegistration) -> {
if (clientRegistration.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.PRIVATE_KEY_JWT)) {
// Assuming RSA key type
RSAPublicKey publicKey = ...
RSAPrivateKey privateKey = ...
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
return null;
};
RestClientAuthorizationCodeTokenResponseClient tokenResponseClient =
new RestClientAuthorizationCodeTokenResponseClient();
tokenResponseClient.addParametersConverter(
new NimbusJwtClientAuthenticationParametersConverter<>(jwkResolver));
client_secret_jwt uses the same NimbusJwtClientAuthenticationParametersConverter, with a resolver that builds
an OctetSequenceKey from clientRegistration.getClientSecret() instead of an asymmetric keypair, and
client-authentication-method: client_secret_jwt; wire it onto a
RestClientClientCredentialsTokenResponseClient the same way for the client_credentials grant.
|
mTLS client authentication ( |
DPoP-bound tokens
RFC 9449 — see Sender-Constrained Tokens: DPoP
and mTLS for the proof-JWT anatomy (htm, htu, iat, jti, ath) and the cnf/jkt confirmation claim in
full.
Authorization-server side. Spring Authorization Server issues DPoP-bound tokens automatically when the
client includes a DPoP proof header on its request to the token endpoint — there is no separate enabling
switch documented for the authorization-server side; sending the header is what turns a request from bearer to
DPoP.
Resource-server side. Once oauth2ResourceServer(o → o.jwt(…)) is configured — the same configuration
already documented on Spring Security — Spring Security supports
verifying DPoP-bound access tokens without a separate .dpop(…) builder call: there is no dedicated DPoP DSL
method on OAuth2ResourceServerConfigurer to write. What the reference documentation shows is the wire format
your resource server must expect and can rely on Spring Security to check:
GET /orders HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0IiwiamtdSI6...
with token_type: "DPoP" in the original token response and a confirmation claim inside the access token JWT:
{
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
}
}
The DPoP request header carries the proof JWT (typ: "dpop+jwt"); Spring Security checks that its embedded
public key hashes (SHA-256) to the same jkt value bound in the access token, and that the proof’s htm/htu
match the actual request and its jti has not been replayed.
The reactive/WebFlux equivalents
Every client-side type above has a direct reactive counterpart:
| Servlet | Reactive |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Bean
ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
ReactiveClientRegistrationRepository clientRegistrations,
ServerOAuth2AuthorizedClientRepository authorizedClients) {
ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.build();
DefaultReactiveOAuth2AuthorizedClientManager manager =
new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrations, authorizedClients);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
@Bean
WebClient partnerApiWebClient(ReactiveClientRegistrationRepository clientRegistrations,
ServerOAuth2AuthorizedClientRepository authorizedClients) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrations, authorizedClients);
oauth2.setDefaultClientRegistrationId("partner-api");
return WebClient.builder()
.baseUrl("https://partner.example.com")
.filter(oauth2)
.build();
}
The device-code, token-exchange and jwt-bearer providers above have no separate reactive class named in the
reference documentation beyond the same .provider(…) composition pattern on the reactive builder; the gaps
noted for device code and mTLS apply identically on the reactive stack.
References
-
RFC 7521 — Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants
-
RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants
-
RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
-
RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)