Spring WebClient and OAuth2: Client Credentials & Authorization Code Flow
Master OAuth2 with Spring WebClient: implement client credentials and authorization code flows.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17 or later
- ✓Spring Boot 3.x
- ✓Basic knowledge of OAuth2 concepts
- ✓Familiarity with reactive programming (Mono/Flux)
- Use WebClient with OAuth2 support via spring-boot-starter-webflux and spring-security-oauth2-client.
- For client credentials flow, configure a ClientRegistration with authorization-grant-type=client_credentials.
- For authorization code flow, use the reactive OAuth2AuthorizedClientManager to handle token refresh automatically.
- In production, always add retry logic for token acquisition failures and monitor token expiry.
- Never share the same WebClient instance across tenants without proper context propagation.
Think of OAuth2 like a hotel key card system. The client credentials flow is like a maintenance worker who has a master key (client ID and secret) that works on all doors (APIs) without asking a guest. The authorization code flow is like a guest checking in at the front desk (authorization server), getting a key card (authorization code), then exchanging it for a room key (access token) that only opens their room (specific user data). WebClient is the bellhop who carries your requests and automatically handles the key card exchange for you.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
If you're building Spring Boot applications that talk to external APIs secured with OAuth2, you've probably felt the pain. I've been there — debugging a production outage at 2 AM because the token refresh logic silently failed, and our payment processing pipeline ground to a halt. The worst part? The logs showed nothing because we were using RestTemplate with a custom interceptor that swallowed exceptions.
Spring WebClient, introduced in Spring 5, is the modern answer. It's reactive, non-blocking, and has first-class OAuth2 support. But here's the hard truth: most teams get the integration wrong. They either over-engineer with custom token managers or under-engineer with no retry logic.
In this article, I'll show you how to implement both the client credentials and authorization code flows using Spring Security's OAuth2 client support. I'll share the patterns I've used in production for SaaS billing systems and real-time analytics pipelines. We'll cover configuration, code examples, debugging techniques, and the gotchas that the official docs gloss over.
By the end, you'll know exactly how to build resilient, secure integrations that won't fail at 3 AM on a Saturday.
Setting Up Dependencies and Configuration
Let's start with the foundation. You need two main dependencies: spring-boot-starter-webflux for WebClient and spring-security-oauth2-client for OAuth2 support. I recommend using the latest Spring Boot 3.x (3.2 at the time of writing) because it includes reactive OAuth2 support out of the box.
Here's the Maven configuration:
``xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> </dependency> ``
Now, configure your OAuth2 client properties. For client credentials flow, you'll need something like this in application.yml:
``yaml spring: security: oauth2: client: registration: my-api: client-id: my-client-id client-secret: my-client-secret authorization-grant-type: client_credentials scope: read,write provider: my-api: token-uri: https://auth.example.com/oauth/token ``
For authorization code flow, it's similar but you need the authorization-uri and redirect-uri:
``yaml spring: security: oauth2: client: registration: my-user-api: client-id: my-user-client-id client-secret: my-user-client-secret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: openid,profile provider: my-user-api: authorization-uri: https://auth.example.com/oauth/authorize token-uri: https://auth.example.com/oauth/token user-info-uri: https://auth.example.com/userinfo user-name-attribute: sub ``
One thing the docs don't tell you: never hardcode client secrets in your config files. Use environment variables or a vault. I've seen secrets leaked to GitHub more times than I care to count.
Creating WebClient with OAuth2 Support
Now let's create a WebClient that automatically handles token acquisition and refresh. The key is to use ServerOAuth2AuthorizedClientExchangeFilterFunction — this is the reactive equivalent of the servlet-based OAuth2AuthorizedClientExchangeFilterFunction.
Here's how to configure it:
```java @Configuration public class WebClientConfig {
@Bean public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); oauth2Filter.setDefaultClientRegistrationId("my-api"); return WebClient.builder() .filter(oauth2Filter) .build(); }
@Bean public ReactiveOAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository clientRegistrationRepository, ReactiveOAuth2AuthorizedClientService authorizedClientService) { ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider = ReactiveOAuth2AuthorizedClientProviderBuilder.builder() .clientCredentials() .refreshToken() .build(); AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager manager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientService); manager.setAuthorizedClientProvider(authorizedClientProvider); return manager; } } ```
Notice I included .refreshToken() in the provider builder. This is critical — without it, token refresh won't work. The official docs show this, but many developers miss it.
Now use the WebClient in your service:
```java @Service public class PaymentService { private final WebClient webClient;
public PaymentService(WebClient webClient) { this.webClient = webClient; }
public Mono<Payment> processPayment(PaymentRequest request) { return webClient.post() .uri("https://api.paymentgateway.com/v1/charges") .bodyValue(request) .retrieve() .bodyToMono(Payment.class); } } ```
What about the authorization code flow? You need to propagate the authenticated user's context. Use @RegisteredOAuth2AuthorizedClient annotation in a controller:
```java @RestController public class UserController { private final WebClient webClient;
public UserController(WebClient webClient) { this.webClient = webClient; }
@GetMapping("/user/profile") public Mono<UserProfile> getProfile( @RegisteredOAuth2AuthorizedClient("my-user-api") OAuth2AuthorizedClient authorizedClient) { return webClient.get() .uri("https://api.example.com/user/profile") .attributes(oauth2AuthorizedClient(authorizedClient)) .retrieve() .bodyToMono(UserProfile.class); } } ```
But wait — in a reactive service layer, you won't have access to the authorized client directly. Instead, inject the WebClient and let the filter handle it. The filter uses the current authentication context from Spring Security's ReactiveSecurityContextHolder. This works seamlessly if you're using Spring Security's OAuth2 login.
Handling Token Refresh and Retry Logic
Token refresh is where most production issues occur. The default behavior is to try refreshing once and fail if it doesn't work. In a real system, you need retries with exponential backoff.
Here's how to add retry logic to your WebClient:
```java @Bean public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); oauth2Filter.setDefaultClientRegistrationId("my-api");
return WebClient.builder() .filter(oauth2Filter) .filter(RetryFilter.create()) // custom retry filter .build(); } ```
And the retry filter:
```java public class RetryFilter implements ExchangeFilterFunction { private static final int MAX_RETRIES = 3; private static final Duration INITIAL_BACKOFF = Duration.ofMillis(500);
public static ExchangeFilterFunction create() { return (request, next) -> next.exchange(request) .flatMap(clientResponse -> { if (clientResponse.statusCode().is5xxServerError() || clientResponse.statusCode().equals(HttpStatus.UNAUTHORIZED)) { return Mono.error(new RetryableException(clientResponse.statusCode())); } return Mono.just(clientResponse); }) .retryWhen(Retry.backoff(MAX_RETRIES, INITIAL_BACKOFF) .filter(throwable -> throwable instanceof RetryableException) .onRetryExhaustedThrow((spec, signal) -> signal.failure())); } } ```
But be careful: retrying on 401 after token refresh may cause infinite loops if the token endpoint itself is down. I recommend using a circuit breaker pattern with resilience4j. Here's a production pattern I use:
```java @Bean public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager, RetryRegistry retryRegistry) { ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); oauth2Filter.setDefaultClientRegistrationId("my-api");
Retry retry = retryRegistry.retry("oauth2-api");
return WebClient.builder() .filter(oauth2Filter) .filter((request, next) -> Retry.decoratePublisher(retry, () -> next.exchange(request)) ) .build(); } ```
This way, if the token server is down, you don't keep hammering it. The circuit breaker opens after a threshold of failures.
What the Official Docs Won't Tell You
Let me save you weeks of debugging. Here are the real gotchas:
- Reactive vs. Servlet stack: If you're using Spring MVC (servlet stack), you cannot use ServerOAuth2AuthorizedClientExchangeFilterFunction. You must use the servlet-based OAuth2AuthorizedClientExchangeFilterFunction. Mixing them will cause ClassCastException at runtime. I've seen teams waste days on this.
- Token caching: The default InMemoryReactiveOAuth2AuthorizedClientService stores tokens in memory. If your application restarts, all tokens are lost. For production, consider using a distributed cache like Redis, especially for authorization code flows where user tokens are long-lived.
- Token refresh race conditions: When multiple requests arrive simultaneously with an expired token, they all trigger a refresh. This can overwhelm your token server. The fix is to use a synchronized token refresh — but in reactive programming, that's tricky. Spring Security's reactive client manager handles this internally by using a lock per registration ID, but only if you use the default implementation.
- Scope handling: Some providers require specific scopes for client credentials. If you omit scopes in your config, the token request may fail. Always include the scopes your API needs.
- Custom token endpoint parameters: Some OAuth2 providers require additional parameters (e.g., audience, resource). The standard client registration properties don't support this. You'll need to implement a custom ReactiveOAuth2AccessTokenResponseClient. Here's a snippet:
```java public class CustomTokenResponseClient implements ReactiveOAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> { private final WebClient webClient = WebClient.create();
@Override public Mono<OAuth2AccessTokenResponse> getTokenResponse( OAuth2ClientCredentialsGrantRequest request) { ClientRegistration registration = request.getClientRegistration(); return webClient.post() .uri(registration.getProviderDetails().getTokenUri()) .body(BodyInserters.fromFormData("grant_type", "client_credentials") .with("client_id", registration.getClientId()) .with("client_secret", registration.getClientSecret()) .with("audience", "https://api.example.com")) .retrieve() .bodyToMono(OAuth2AccessTokenResponse.class); } } ```
Then register it in your configuration.
Testing OAuth2 with WebClient
Testing OAuth2 flows can be painful because you don't want to hit real authorization servers in unit tests. Use WireMock to mock the token endpoint and the downstream API.
First, add WireMock dependency:
``xml <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> ``
Now write a test for the client credentials flow:
```java @SpringBootTest @AutoConfigureWebTestClient @AutoConfigureMockMvc public class PaymentServiceTest {
@Autowired private WebTestClient webTestClient;
@Test public void testProcessPayment() { // Mock token endpoint stubFor(post(urlEqualTo("/oauth/token")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBody("{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}")));
// Mock downstream API stubFor(post(urlEqualTo("/v1/charges")) .withHeader("Authorization", equalTo("Bearer test-token")) .willReturn(aResponse() .withStatus(200) .withBody("{\"id\":\"ch_123\",\"status\":\"succeeded\"}")));
webTestClient.post() .uri("/api/payments") .bodyValue(new PaymentRequest(100, "usd")) .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.status").isEqualTo("succeeded"); } } ```
For authorization code flow, you need to simulate a logged-in user. Use @WithMockUser with OAuth2 attributes:
``java @Test @WithMockUser(authorities = "SCOPE_openid") public void testGetProfile() { // Mock token endpoint with refresh token // ... } ``
But the real trick is testing token refresh scenarios. I recommend creating a test that: 1. First call succeeds with a short-lived token. 2. Second call triggers a refresh (mock token endpoint to return a new token). 3. Third call uses the new token.
This ensures your retry and refresh logic works end-to-end.
Production Best Practices and Monitoring
You've got WebClient working with OAuth2. Now let's make it production-ready.
1. Monitor token expiry and refresh latency. Add metrics for: - Token acquisition time (histogram) - Token refresh count (counter) - Token expiry time (gauge) - Number of 401 responses before successful refresh (counter)
Use Micrometer to expose these metrics:
``java @Bean public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager, MeterRegistry meterRegistry) { // ... return ``WebClient.builder() .filter(oauth2Filter) .filter(MetricsFilter.create(meterRegistry)) .build(); }
2. Use a dedicated connection pool. WebClient uses a connection pool from Reactor Netty. Configure it properly:
``java @Bean public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { ConnectionProvider provider = ConnectionProvider.builder("oauth2-pool") .maxConnections(50) .pendingAcquireTimeout(Duration.ofSeconds(30)) .maxIdleTime(Duration.ofSeconds(20)) .build(); return ``WebClient.builder() .clientConnector(new ReactorClientHttpConnector( HttpClient.create(provider))) .filter(oauth2Filter) .build(); }
3. Logging and debugging. Enable DEBUG logging for OAuth2 classes:
``yaml logging: level: org.springframework.security.oauth2: DEBUG org.springframework.web.reactive.function.client: DEBUG ``
But be careful in production — these logs can be verbose. Use a conditional logger or sample rates.
4. Graceful degradation. If token acquisition fails, don't let your entire service go down. Return fallback responses or cached data. Use Hystrix or resilience4j for bulkheading.
5. Secret rotation. If your client secret changes, you need a way to update it without restarting the application. Use Spring Cloud Config with a refresh scope or a secrets manager that supports dynamic secrets.
The Silent Token Refresh Failure in Our Payment Gateway
OAuth2AuthorizedClientManager.getAuthorizedClient() on a reactive thread. When the access token expired, the blocking call caused the scheduler to hang, leading to thread starvation and cascading timeouts.- Never block in a reactive pipeline — use the reactive OAuth2 support classes.
- Always add retry logic for token acquisition; token servers can fail.
- Monitor token expiry and refresh latency as separate metrics.
- Test token refresh under load — not just happy path.
- Use separate WebClient instances for different OAuth2 client registrations to avoid context leaks.
curl -v -X POST <token-endpoint> -d 'grant_type=client_credentials&client_id=...&client_secret=...'Check WebClient filter: ensure ServerOAuth2AuthorizedClientExchangeFilterFunction is applied.| File | Command / Code | Purpose |
|---|---|---|
| application.yml | spring: | Setting Up Dependencies and Configuration |
| WebClientConfig.java | @Configuration | Creating WebClient with OAuth2 Support |
| RetryFilter.java | public class RetryFilter implements ExchangeFilterFunction { | Handling Token Refresh and Retry Logic |
| CustomTokenResponseClient.java | public class CustomTokenResponseClient implements ReactiveOAuth2AccessTokenRespo... | What the Official Docs Won't Tell You |
| PaymentServiceTest.java | @SpringBootTest | Testing OAuth2 with WebClient |
| application.yml | logging: | Production Best Practices and Monitoring |
Key takeaways
Interview Questions on This Topic
Explain how to configure WebClient for client credentials OAuth2 flow in a Spring Boot reactive application.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't