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 (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

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 (draft-ietf-oauth-v2-1-16) requires PKCE for every authorization_code client, confidential or not — it closes an authorization-code-injection gap that a client secret alone does not. Spring Authorization Server already reflects this: as Authorization Server & Social Login notes, PKCE is required by default for authorization_code, including for confidential clients, unless a client opts out per registration (ClientSettings.requireProofKey(false)). When your service is instead the client of a third-party authorization server that still treats PKCE as optional for confidential clients, send the code_challenge / code_verifier pair anyway — Spring’s authorizationCode() provider and OAuth2AuthorizationRequestResolver attach it automatically, most providers accept it even from a confidential client, and doing so costs nothing while closing the same gap OAuth 2.1 is closing.

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 (tls_client_auth / self_signed_tls_client_auth, RFC 8705) is not documented on Spring Security’s OAuth2 client reference pages — neither the client-authentication page nor the authorization-grants page shows a configuration shape or a property value for it. Do not treat the absence as an oversight to paper over: it means mTLS client authentication for an outbound Spring OAuth2 client is not a solved, documented Spring Security concern the way the four methods above are. What is documented, and what this repository documents in full, is the protocol itself — see Sender-Constrained Tokens: DPoP and mTLS and RFC 8705 directly. In practice, mTLS client authentication is configured at the HTTP client / TLS layer beneath Spring Security — the key and trust material used by the underlying RestClient/WebClient connector (or a mesh/sidecar terminating mTLS on the service’s behalf) — rather than through an OAuth2-specific Spring Security API. If your authorization server requires tls_client_auth, budget for hand-wiring the TLS material yourself; do not expect a client-authentication-method: tls_client_auth property to exist.

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

OAuth2AuthorizedClientManager

ReactiveOAuth2AuthorizedClientManager

DefaultOAuth2AuthorizedClientManager

DefaultReactiveOAuth2AuthorizedClientManager

OAuth2AuthorizedClientProvider

ReactiveOAuth2AuthorizedClientProvider

OAuth2AuthorizedClientProviderBuilder

ReactiveOAuth2AuthorizedClientProviderBuilder

ClientRegistrationRepository

ReactiveClientRegistrationRepository

OAuth2AuthorizedClientRepository

ServerOAuth2AuthorizedClientRepository

OAuth2ClientHttpRequestInterceptor (on RestClient)

ServerOAuth2AuthorizedClientExchangeFilterFunction (on WebClient)

@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.