How to Use OAuth2AuthorizedClientManager for Programmatic Token Exchange in Spring
Purpose
This post demonstrates how to programmatically perform OAuth 2.0 Token Exchange using OAuth2AuthorizedClientManager in Spring Security.
Environment
- Spring Boot 3.3.x
- Spring Security 6.3.x
- Java 21
- OAuth 2.0 Authorization Server
The Problem
When I built a microservices architecture with OAuth 2.0 authentication, I got this problem:
How do I call a downstream service from my controller using a token exchanged from the original JWT?My frontend sends a JWT to Service A. Service A needs to call Service B, but Service B requires a different scope. I needed to exchange the incoming token for a new one with the right permissions.
At first, I tried to manually extract the token and call the authorization server:
@RestControllerpublic class UserController {
@GetMapping("/user/message") public String message(@RequestHeader("Authorization") String authHeader) { // Extract token manually String token = authHeader.replace("Bearer ", "");
// Call authorization server to exchange token - THIS IS WRONG String newToken = callAuthServerForExchange(token);
// Call downstream service return callDownstreamService(newToken); }
private String callAuthServerForExchange(String token) { // This is complex and error-prone! // Need to handle token caching, refresh, expiration... throw new UnsupportedOperationException("Too complex!"); }}This approach has several problems:
- Manual HTTP calls to the authorization server
- No token caching
- No automatic refresh when tokens expire
- Lots of boilerplate code
- Error-prone token handling
What I tried first
I searched for Spring Security’s built-in support and found OAuth2AuthorizedClientManager. But when I tried to use it, I got confused about how to set it up:
@RestControllerpublic class UserController {
@Autowired private OAuth2AuthorizedClientManager authorizedClientManager;
@GetMapping("/user/message") public String message(JwtAuthenticationToken jwtAuthentication) { // How do I use this manager? // What parameters do I need? OAuth2AuthorizedClient client = authorizedClientManager.authorize(???); // ... }}The documentation showed examples, but I couldn’t figure out how to wire everything together for programmatic token exchange.
The Solution: OAuth2AuthorizeRequest
After reading the Spring Security documentation and source code, I found that OAuth2AuthorizeRequest is the key class for building token exchange requests.
Here’s the complete solution:
Step 1: Configure the OAuth2 Client
spring: security: oauth2: client: registration: my-message-service: client-id: ${CLIENT_ID} client-secret: ${CLIENT_SECRET} authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange scope: message:read provider: my-message-service: token-uri: https://auth.example.com/oauth2/tokenStep 2: Configure OAuth2AuthorizedClientManager Bean
@Configuration@EnableWebSecuritypublic class SecurityConfig {
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); }
@Bean public OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() .clientCredentials() .password() .build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager( clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager; }}Step 3: Use the Manager in Controller
@RestControllerpublic class UserController {
private final OAuth2AuthorizedClientManager authorizedClientManager; private final RestClient restClient;
public UserController(OAuth2AuthorizedClientManager authorizedClientManager, RestClient.Builder restClientBuilder) { this.authorizedClientManager = authorizedClientManager; this.restClient = restClientBuilder.build(); }
@GetMapping("/user/message") public String message(JwtAuthenticationToken jwtAuthentication) { // Build authorize request OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest .withClientRegistrationId("my-message-service") .principal(jwtAuthentication) .build();
// Perform token exchange OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);
// Get the new access token OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
// Call downstream service with exchanged token RestClient.ResponseSpec responseSpec = restClient .get() .uri("http://localhost:8082/message") .headers(headers -> headers.setBearerAuth(accessToken.getTokenValue())) .retrieve();
ResponseEntity<String> responseEntity = responseSpec.toEntity(String.class); return responseEntity.getBody(); }}Now test it:
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9..." http://localhost:8080/user/messageReturns the response from the downstream service:
{"message": "Hello from downstream service!"}You can see that I succeeded in exchanging tokens programmatically and calling the downstream service.
Why This Works
I think the key reasons this approach works are:
-
Automatic Token Detection: The
OAuth2AuthorizedClientManagerautomatically detects the incoming JWT from the security context. When you passJwtAuthenticationTokenas the principal, the manager knows to use it for token exchange. -
Token Caching: The manager caches tokens using
OAuth2AuthorizedClientRepository, so repeated calls don’t require new token exchanges until the token expires. -
Automatic Refresh: If the exchanged token expires, the manager automatically refreshes it using the refresh token flow.
-
Clean Separation: The controller only needs to build a request and call
authorize(). All the complexity of HTTP calls, token parsing, and caching is handled internally.
Here’s the flow diagram:
+----------------+ +------------------+ +--------------------+| Frontend | | Service A | | Authorization || (Browser) | | (Controller) | | Server |+-------+--------+ +--------+---------+ +----------+---------+ | | | | 1. JWT with scope A | | |---------------------->| | | | | | | 2. OAuth2AuthorizeRequest| | | with principal | | |------------------------->| | | | | | 3. Token Exchange | | | (scope B) | | |<-------------------------| | | | | | 4. Call Service B | | | with new token | | |--------+ | | | | | | | 5. Response | |<------------------------------| | | | |Common Mistakes I Made
Mistake 1: Not Injecting OAuth2AuthorizedClientManager
I initially tried to create the manager inside the controller:
@RestControllerpublic class UserController {
@GetMapping("/user/message") public String message(JwtAuthenticationToken jwtAuthentication) { // WRONG: Creating manager manually OAuth2AuthorizedClientManager manager = new DefaultOAuth2AuthorizedClientManager(...); // This won't work - missing repositories and configuration }}This fails because the manager needs ClientRegistrationRepository and OAuth2AuthorizedClientRepository beans that are auto-configured by Spring Security.
Solution: Inject the manager as a dependency through constructor injection.
Mistake 2: Using Wrong Client Registration ID
I used a client registration ID that didn’t exist:
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest .withClientRegistrationId("non-existent-client") // WRONG! .principal(jwtAuthentication) .build();This throws:
IllegalArgumentException: Could not find ClientRegistration with registration id 'non-existent-client'Solution: The client registration ID must match exactly what’s defined in application.yml.
Mistake 3: Forgetting Bearer Auth Header
I tried to call the downstream service without setting the Bearer auth header:
// WRONG: No auth headerString response = restClient .get() .uri("http://localhost:8082/message") .retrieve() .body(String.class);The downstream service returns 401 Unauthorized.
Solution: Always set the Bearer auth header with the token value:
.headers(headers -> headers.setBearerAuth(accessToken.getTokenValue()))Mistake 4: Using Wrong Principal Type
I tried using Principal interface instead of JwtAuthenticationToken:
@GetMapping("/user/message")public String message(Principal principal) { OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest .withClientRegistrationId("my-message-service") .principal(principal) // This might work, but loses JWT-specific info .build();}While this compiles, it may not work correctly for token exchange because the manager needs access to the JWT claims.
Solution: Use JwtAuthenticationToken as the parameter type for better type safety and access to JWT-specific information.
Additional Configuration
For the token exchange grant type, you need to configure the authorization server to support it. Here’s a minimal Keycloak configuration:
# Create client with token exchange enabled$ kcadm.sh create clients -r myrealm -s clientId=my-message-service \ -s secret=my-secret \ -s enabled=true \ -s protocol=openid-connect \ -s 'attributes."oauth2.grant.type"=["urn:ietf:params:oauth:grant-type:token-exchange"]'For Spring Authorization Server:
@Configurationpublic class AuthorizationServerConfig {
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client = RegisteredClient.withId("my-message-service") .clientId("my-message-service") .clientSecret("{noop}my-secret") .authorizationGrantType(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:token-exchange")) .scope("message:read") .build();
return new InMemoryRegisteredClientRepository(client); }}Testing the Implementation
Here’s a test to verify the token exchange works:
@SpringBootTest@AutoConfigureMockMvcclass UserControllerTest {
@Autowired private MockMvc mockMvc;
@Test @WithMockJwtAuthenticationToken void testMessageEndpoint() throws Exception { mockMvc.perform(get("/user/message")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").exists()); }}Summary
In this post, I showed how to programmatically perform OAuth 2.0 Token Exchange using OAuth2AuthorizedClientManager in Spring Security. The key points are:
- Inject
OAuth2AuthorizedClientManageras a bean - Build
OAuth2AuthorizeRequestwith client registration ID and principal - Call
authorize()to getOAuth2AuthorizedClient - Extract access token and use with RestClient
This approach handles token caching, refresh, and expiration automatically, keeping your code clean and maintainable.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Security OAuth 2.0 Client Documentation
- 👨💻 OAuth 2.0 Token Exchange (RFC 8693)
- 👨💻 Spring Security OAuth2AuthorizedClientManager API
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments