Revision note (2026-09-15). The earlier version of this article had a service example that does not compile: it passed a lambda to OAuth2AuthorizeRequest.Builder.principal(...), which accepts a String or an Authentication, and its troubleshooting table told readers to make sure "the dummy principal lambda returns a non-null string". It declared spring-boot-starter-webflux next to spring-boot-starter-web only to call WebClient.block() from a servlet application, and an earlier revision reviewed on 2026-08-20 passed null as the OAuth2AuthorizedClientService. It also stated that the manager "automatically detects token expiry and obtains a new token" without mentioning the 60-second clock skew that decides when a token counts as expired. This version is built from a lab with three Spring Boot 3.5.16 applications (a Spring Authorization Server, a JWT resource server, and the client), nine integration tests that run against the real servers, and a recorded curl session. The lab is in examples/spring-oauth2-client-credentials of the site repository.
What the grant is and what the lab looks like
The client credentials grant (RFC 6749, section 4.4) is for a service calling another service with no user involved: the client authenticates with its own id and secret at the token endpoint and receives an access token for itself. There is no refresh token; when the token expires the client asks for a new one.
Tested versions: Spring Boot 3.5.16, Spring Security 6.5.11, Spring Authorization Server 1.5.8, Spring Framework 6.2.19, Java 17 (Amazon Corretto 17.0.14), Gradle 8.8.
Three applications, all in one Gradle build:
| Module | Port | Role |
|---|---|---|
auth-server | 9000 | Spring Authorization Server issuing self-contained JWTs; registers demo-client (5-minute tokens) and short-lived-client (8-second tokens); a lab-only /internal/issued-tokens counter |
resource-server | 9002 | spring-boot-starter-oauth2-resource-server, validates JWTs against the issuer, serves GET /api/orders to SCOPE_orders.read |
client-app | 9001 | servlet stack, RestClient, calls the resource server with a machine token |
client-webflux | 9001 (alternative to client-app) | the same client on the reactive stack, WebClient |
The client tests start the auth server and the resource server from their boot jars, so nothing in the tests is mocked.
The authorization server, briefly
spring-boot-starter-oauth2-authorization-server plus one RegisteredClient per caller is enough:
RegisteredClient demoClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("demo-client")
.clientSecret("{noop}demo-secret") // lab only; use a password encoder and a secret store in production
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders.read")
.tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofMinutes(5)).build())
.build();
With spring.security.oauth2.authorizationserver.issuer=http://localhost:9000 the server publishes its metadata and JWK set, which the resource server reads at startup from spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000. The token endpoint, from the recorded session:
$ curl -s -u demo-client:demo-secret -d "grant_type=client_credentials&scope=orders.read" http://localhost:9000/oauth2/token
{"access_token":"eyJraWQiOiI5ZDQ0ZDBk...<trimmed>","scope":"orders.read","token_type":"Bearer","expires_in":299}
The payload of that JWT: {"sub":"demo-client","aud":"demo-client","scope":["orders.read"],"iss":"http://localhost:9000","exp":...,"iat":...}. Note that scope is a JSON array; on the resource server read it with jwt.getClaimAsStringList("scope"), not getClaimAsString, which returns the list's toString().
The client: servlet stack, no WebFlux
Dependencies:
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
That is all. spring-boot-starter-oauth2-client brings spring-security-oauth2-client and the auto-configuration for the spring.security.oauth2.client.* properties. RestClient is part of spring-web; no reactive dependency is needed.
spring:
security:
oauth2:
client:
registration:
demo-client:
client-id: demo-client
client-secret: ${DEMO_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: orders.read
provider: lab-auth-server
provider:
lab-auth-server:
token-uri: http://localhost:9000/oauth2/token
From these properties Spring Boot creates a ClientRegistrationRepository and an OAuth2AuthorizedClientService (an InMemoryOAuth2AuthorizedClientService). The service is the token cache. It is the second constructor argument of the manager and it must be a real instance; the wiring:
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService);
manager.setAuthorizedClientProvider(provider);
return manager;
}
Why this manager and not DefaultOAuth2AuthorizedClientManager: the default one stores authorized clients through an OAuth2AuthorizedClientRepository, which is bound to the current HttpServletRequest. Service-to-service calls usually happen where there is no request (a scheduler, a message listener, a startup task), and that is what AuthorizedClientServiceOAuth2AuthorizedClientManager is for. It works inside web requests too.
Attaching the token to outgoing calls is done by OAuth2ClientHttpRequestInterceptor (Spring Security 6.4 and later), which uses the manager for every request that names a registration:
@Bean
RestClient ordersRestClient(RestClient.Builder builder, OAuth2AuthorizedClientManager authorizedClientManager) {
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
interceptor.setClientRegistrationIdResolver(new RequestAttributeClientRegistrationIdResolver());
return builder
.baseUrl("http://localhost:9002/api/orders")
.requestInterceptor(interceptor)
.build();
}
@Service
public class OrdersClient {
private final RestClient ordersRestClient;
public OrdersClient(RestClient ordersRestClient) {
this.ordersRestClient = ordersRestClient;
}
public Map<String, Object> fetchOrders() {
return ordersRestClient.get()
.attributes(clientRegistrationId("demo-client")) // static import from RequestAttributeClientRegistrationIdResolver
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
}
If you prefer to obtain the token yourself, the manager call looks like this. The principal is a plain name, not a lambda:
OAuth2AuthorizedClient client = authorizedClientManager.authorize(
OAuth2AuthorizeRequest.withClientRegistrationId("demo-client")
.principal("orders-batch-job")
.build());
String token = client.getAccessToken().getTokenValue();
The earlier version wrote .principal(() -> "machine-client"). Compiled against Spring Security 6.5.11:
ArticleSnippet.java:7: error: no suitable method found for principal(()->"machi[...]ient")
method Builder.principal(String) is not applicable
(argument mismatch; String is not a functional interface)
method Builder.principal(Authentication) is not applicable
(argument mismatch; Authentication is not a functional interface
multiple non-overriding abstract methods found in interface Authentication)
The client app exposes GET /demo/orders, which calls OrdersClient. From the session:
$ curl -s -w "nHTTP %{http_code}n" http://localhost:9001/demo/orders
{"caller":"demo-client","scope":["orders.read"],"orders":["order-1001","order-1002"],"tokenExpiresAt":"2026-09-15T05:02:06Z"}
HTTP 200
$ for i in 1 2 3; do curl -s -o /dev/null http://localhost:9001/demo/orders; done; curl -s http://localhost:9000/internal/issued-tokens
{"demo-client":1}
Four calls, one token issued.
What the tests proved
All tests are in ClientCredentialsIntegrationTests (servlet client) and WebFluxClientCredentialsTests. The auth server's /internal/issued-tokens counter is incremented by an OAuth2TokenCustomizer, which runs once per issued access token, so "how many tokens did the server issue" is a direct observation, not an inference from token strings.
Token obtained (tokenIsObtainedAndTheProtectedResourceAnswers): authorize() returns a client whose token expires more than four minutes out, the injected OAuth2AuthorizedClientService holds it under ("demo-client", "orders-batch-job"), and /demo/orders answers with the resource server's payload.
Cached and reused (tokenIsCachedAndReusedBeforeExpiry): three authorize() calls return the same token value and the issued-token counter does not move after the first.
Refreshed after expiry (tokenIsRefreshedAfterExpiryWhenClockSkewIsSmallerThanTheTtl): against short-lived-client with 8-second tokens and a provider configured with clockSkew(Duration.ofSeconds(2)), the second call reuses the token, and after a 7-second sleep the next call gets a different token with a later expiresAt; the counter reads 2.
The 60-second clock skew (defaultClockSkewMakesAnEightSecondTokenUnusableForCaching): the same 8-second registration with the default provider issues a new token on every authorize() call. ClientCredentialsOAuth2AuthorizedClientProvider considers a token expired once now > expiresAt - clockSkew, and the default skew is 60 seconds, so a token that lives 60 seconds or less is expired the moment it arrives. This is not a bug; it protects against clock drift between client and server. It does mean that if your authorization server issues short tokens, every call becomes a token request unless you lower the skew:
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials(c -> c.clockSkew(Duration.ofSeconds(5)))
.build();
Watch the token endpoint's request rate after deploying a client; "it works" and "it fetches a token per request" look the same from the caller's side.
The cache key (cacheIsKeyedByRegistrationAndPrincipalName): the store is keyed by registration id and principal name. The interceptor resolves the principal from the SecurityContext; on a permitAll endpoint, or with no request at all, that is the anonymous authentication, whose name is anonymousUser. A manual authorize() with the principal a-different-principal-name produced a second token request and a second entry in the service. Pick one principal name per registration and use it everywhere, or route every call through the interceptor.
Wrong secret (wrongClientSecretIsRejectedWithInvalidClientAnd401): the server answers the token request with HTTP 401 and {"error":"invalid_client"}, and the client throws ClientAuthorizationException whose getClientRegistrationId() names the registration. Two details worth knowing before writing a catch block: the exception's error code is invalid_token_response, not invalid_client, and the description reads An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: 401 : [no body]. The server's error code is not surfaced as the client-side code.
Resource server (resourceServerRejectsMissingAndGarbageTokens): no token or a non-JWT bearer value gives HTTP 401 from /api/orders.
ClientCredentialsIntegrationTests > tokenIsRefreshedAfterExpiryWhenClockSkewIsSmallerThanTheTtl() PASSED
ClientCredentialsIntegrationTests > defaultClockSkewMakesAnEightSecondTokenUnusableForCaching() PASSED
ClientCredentialsIntegrationTests > wrongClientSecretIsRejectedWithInvalidClientAnd401() PASSED
ClientCredentialsIntegrationTests > tokenIsCachedAndReusedBeforeExpiry() PASSED
ClientCredentialsIntegrationTests > tokenIsObtainedAndTheProtectedResourceAnswers() PASSED
ClientCredentialsIntegrationTests > cacheIsKeyedByRegistrationAndPrincipalName() PASSED
ClientCredentialsIntegrationTests > resourceServerRejectsMissingAndGarbageTokens() PASSED
WebFluxClientCredentialsTests > reactiveManagerObtainsATokenAndTheFilterUsesIt() PASSED
WebFluxClientCredentialsTests > filterReusesTheCachedTokenAcrossRequests() PASSED
The WebFlux variant
If the calling application is reactive, use the reactive counterparts of everything above, and only then does WebClient belong in the picture. Dependencies: spring-boot-starter-webflux and spring-boot-starter-oauth2-client, without spring-boot-starter-web.
@Bean
ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
ReactiveClientRegistrationRepository clientRegistrationRepository,
ReactiveOAuth2AuthorizedClientService authorizedClientService) {
ReactiveOAuth2AuthorizedClientProvider provider = ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientService);
manager.setAuthorizedClientProvider(provider);
return manager;
}
@Bean
WebClient ordersWebClient(WebClient.Builder builder, ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2.setDefaultClientRegistrationId("demo-client");
return builder.baseUrl("http://localhost:9002/api/orders").filter(oauth2).build();
}
public Mono<Map<String, Object>> orders() {
return ordersWebClient.get().retrieve().bodyToMono(new ParameterizedTypeReference<>() {});
}
Two tests cover it: the reactive manager obtains a token and the filter uses it (/demo/orders returns the resource server payload), and four requests through the filter leave the issued-token counter unchanged after the first. The expiry and wrong-secret cases were only tested on the servlet client.
Mixing the two stacks, as the earlier version did, is what produces the confusion the old text tried to explain away: a servlet application with WebClient.block() needs the reactive dependency, gets Reactor threads it does not otherwise use, and still cannot use the servlet OAuth2ClientHttpRequestInterceptor on that WebClient. Choose the stack the application already runs on.
Operational notes
- The in-memory service is per JVM. Every instance of a scaled-out service fetches its own token, and a restart starts empty. That is normal for this grant; the cost is one token request per instance per token lifetime. If that is too many, Spring Security ships
JdbcOAuth2AuthorizedClientService, which this lab did not exercise. - Token lifetime is the server's decision. Read
expires_infrom a real token response before choosing a clock skew, and remember the resource server has its own skew when validatingexp(the defaultJwtTimestampValidatoralso allows 60 seconds; the lab did not test an expired token against the resource server). - Secrets: the lab's
{noop}demo-secretand the${DEMO_CLIENT_SECRET:demo-secret}fallback exist only so the tests run without setup. Production configuration takes the secret from a secret store or environment and uses a password encoder on the server. - The
ClientAuthorizationExceptionfrom a wrong secret or an unreachable token endpoint is thrown from inside theRestClientcall. Retrying it blindly will hammer the token endpoint; a wrong secret does not fix itself.
What this does not cover
- Opaque tokens and token introspection; only self-contained JWTs were used.
client_secret_jwtandprivate_key_jwtclient authentication, mTLS, and TLS in general (the lab runs on plain HTTP on localhost).- Multi-instance behaviour and persistent token stores.
- Resource-server handling of an expired token, and the reactive client's expiry behaviour.
- Retry, circuit-breaking, and rate limiting around the token endpoint.
Reproduce it
cd examples/spring-oauth2-client-credentials
gradle --no-daemon test # builds the auth and resource server jars, starts them, runs 9 tests
gradle --no-daemon :auth-server:bootRun # then :resource-server:bootRun and :client-app:bootRun in two more shells
curl -s http://localhost:9001/demo/orders
curl -s http://localhost:9000/internal/issued-tokens
Sources
- Spring Security reference: OAuth2 client, authorized clients (RestClient integration, OAuth2ClientHttpRequestInterceptor)
- Spring Security reference: OAuth2 client, authorization grant support (client credentials)
- Spring Authorization Server reference: getting started
- Spring Boot reference: Spring Security (OAuth2 client and resource server auto-configuration)
- RFC 6749, section 4.4: Client Credentials Grant
