How to Test OAuth 2.0 Token Exchange End-to-End in Spring Boot
Purpose
This post demonstrates how to test OAuth 2.0 Token Exchange flow end-to-end across client app, resource server, and authorization server.
Problem
I configured token exchange in my Spring Boot application following all the documentation. The configuration looked correct, but I couldn’t verify if it actually worked.
I had three services running:
+-------------------+ +------------------+ +-------------------+| Client App | | User Service | | Message Service || (port 8080) | | (port 8081) | | (port 8082) |+-------------------+ +------------------+ +-------------------+ | | | | | | +---------> Authorization Server (port 9001) <-----+But when I tested:
# I opened browser to http://localhost:8080# Got redirected to login# Logged in with baeldung/password# But then what?# How do I know token exchange happened?# How do I verify the audience changed?I was stuck. The services were running, but I had no visibility into whether token exchange was actually happening.
Environment
- Spring Boot 3.3+
- Spring Security 6.3+
- Spring Authorization Server 1.3+
- Java 21
- Browser (Chrome/Firefox for testing)
What I Tried First (And Failed)
Attempt 1: Just Click Around
I logged in and clicked the link to call the protected endpoint. The response came back. But was token exchange happening? I had no idea.
Attempt 2: Check Logs
I checked the application logs:
2026-03-26 10:15:23 INFO [nio-8080-exec-1] c.e.c.ClientController : Calling resource server2026-03-26 10:15:23 INFO [nio-8081-exec-1] c.e.u.UserController : Processing request2026-03-26 10:15:23 DEBUG [nio-8081-exec-1] o.s.s.o.a.JwtAuthenticationProvider : Authenticated principalThe logs showed requests were processed, but nothing about token exchange.
Attempt 3: Debug Mode
I enabled debug logging:
logging: level: org.springframework.security: DEBUG org.springframework.security.oauth2: TRACENow I got more details:
2026-03-26 10:20:15 TRACE [nio-8081-exec-1] o.s.s.o.a.AuthenticatedPrincipalOAuth2AuthorizedClientRepository : Loading authorized client2026-03-26 10:20:15 TRACE [nio-8081-exec-1] o.s.s.o.a.OAuth2AuthorizedClientManager : Authorizing client2026-03-26 10:20:15 DEBUG [nio-8081-exec-1] o.s.s.o.a.DefaultOAuth2TokenExchangeAuthorizedClientProvider : Exchanging tokenThis showed token exchange was happening! But I still needed to verify the actual tokens.
The Solution: Complete End-to-End Test
Here’s how I finally tested the complete flow with full visibility.
Step 1: Start All Services
First, I made sure all three services were running:
# Terminal 1: Authorization Servercd auth-server && java -jar target/auth-server.jar# Should see: Started AuthServerApplication on port 9001
# Terminal 2: User Service (Resource Server)cd user-service && java -jar target/user-service.jar# Should see: Started UserServiceApplication on port 8081
# Terminal 3: Message Service (Downstream)cd message-service && java -jar target/message-service.jar# Should see: Started MessageServiceApplication on port 8082
# Terminal 4: Client Applicationcd client-app && java -jar target/client-app.jar# Should see: Started ClientApplication on port 8080I verified all services were up:
curl -s http://localhost:9001/actuator/health | jq .status# "UP"
curl -s http://localhost:8081/actuator/health | jq .status# "UP"
curl -s http://localhost:8082/actuator/health | jq .status# "UP"
curl -s http://localhost:8080/actuator/health | jq .status# "UP"Step 2: Enable Token Logging
I added a filter to log incoming and outgoing tokens:
@Componentpublic class TokenLoggingFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(TokenLoggingFilter.class);
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request; String authHeader = httpRequest.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); logTokenClaims(token, "Incoming token"); }
chain.doFilter(request, response); }
private void logTokenClaims(String token, String label) { try { String[] chunks = token.split("\\."); String payload = new String(Base64.getUrlDecoder().decode(chunks[1])); log.info("{} claims: {}", label, payload); } catch (Exception e) { log.error("Failed to decode token", e); } }}Step 3: Browser-Based Login Test
I opened my browser to the client application:
1. Navigate to: http://localhost:80802. Browser redirects to: http://localhost:9001/login3. Login with: username=baeldung, password=password4. After successful login, redirected back to: http://localhost:80805. Browser now has session with client appIn the authorization server logs, I saw:
2026-03-26 10:25:30 INFO [nio-9001-exec-1] o.s.s.w.a.UsernamePasswordAuthenticationFilter : User authenticated: baeldung2026-03-26 10:25:30 INFO [nio-9001-exec-1] o.s.s.a.server.OAuth2AuthorizationServer : Authorization code issuedStep 4: Trigger Token Exchange
I clicked the link that calls the protected endpoint requiring token exchange:
@RestControllerpublic class ClientController { private static final String TARGET_RESOURCE_SERVER_URL = "http://localhost:8081/user/message";
@GetMapping("/api/user/message") public String userMessage( @RegisteredOAuth2AuthorizedClient(registrationId = "messaging-client-oidc") OAuth2AuthorizedClient oauth2AuthorizedClient) {
RestClient.ResponseSpec responseSpec = restClient .get() .uri(TARGET_RESOURCE_SERVER_URL) .headers(headers -> headers.setBearerAuth( oauth2AuthorizedClient.getAccessToken().getTokenValue())) .retrieve();
String messageFromResourceServer = responseSpec.toEntity(String.class).getBody();
return "<html><body><title>Token Exchange</title>" + "<p>Token Exchange Client!</p></br>" + "<p>The resource server: <strong>" + messageFromResourceServer + "</strong></p></body></html>"; }}The client app made a request to the resource server with the token.
Step 5: Verify Token Claims
Now the interesting part. I checked the logs to see the token claims:
Original Token (presented to user-service at port 8081):
{ "sub": "baeldung", "aud": "user-service", "scope": ["openid", "user.read"], "iss": "http://localhost:9001", "exp": 1709234567, "iat": 1709230967}Exchanged Token (used for message-service at port 8082):
{ "sub": "baeldung", "aud": "message-service", "scope": ["message.read"], "iss": "http://localhost:9001", "exp": 1709234667, "iat": 1709231067, "act": { "sub": "user-service" }}Key observations:
+------------------+----------------------+------------------------+| Claim | Original Token | Exchanged Token |+------------------+----------------------+------------------------+| sub (subject) | baeldung | baeldung (PRESERVED) |+------------------+----------------------+------------------------+| aud (audience) | user-service | message-service || | | (CHANGED!) |+------------------+----------------------+------------------------+| scope | openid, user.read | message.read || | | (DIFFERENT!) |+------------------+----------------------+------------------------+| act (actor) | (not present) | {sub: user-service} || | | (NEW!) |+------------------+----------------------+------------------------+Step 6: Verify End-to-End with curl
For a more controlled test, I used curl to simulate the flow:
# Step 1: Get authorization code (use browser or curl)# Navigate to: http://localhost:9001/oauth2/authorize?response_type=code&client_id=client-app&redirect_uri=http://localhost:8080/login/oauth2/code/messaging-client-oidc&scope=openid%20user.read
# Step 2: Exchange code for tokencurl -X POST http://localhost:9001/oauth2/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=YOUR_CODE_HERE" \ -d "redirect_uri=http://localhost:8080/login/oauth2/code/messaging-client-oidc" \ -d "client_id=client-app" \ -d "client_secret=secret"
# Response:# {# "access_token": "eyJhbGciOiJSUzI1NiIs...",# "token_type": "Bearer",# "expires_in": 300,# "scope": "openid user.read"# }
# Step 3: Decode the access tokenecho "eyJhbGciOiJSUzI1NiIs..." | cut -d'.' -f2 | base64 -d | jq .# Shows: {"sub":"baeldung","aud":"user-service",...}
# Step 4: Call the resource server (triggers token exchange)curl http://localhost:8081/user/message \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
# Response:# "Message from user-service: Hello baeldung!"
# Step 5: Check user-service logs for exchanged token# Look for: "Outgoing token claims: {"sub":"baeldung","aud":"message-service",...}"Step 7: Automated Integration Test
I also wrote an automated test to verify the flow:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class TokenExchangeIntegrationTest {
@LocalServerPort private int port;
@Autowired private WebTestClient webTestClient;
@Test void whenCallProtectedEndpoint_thenTokenExchangeHappens() { // Given: Simulated authentication String originalToken = generateTestToken("user-service");
// When: Call the protected endpoint String response = webTestClient.get() .uri("/api/user/message") .headers(headers -> headers.setBearerAuth(originalToken)) .exchange() .expectStatus().isOk() .expectBody(String.class) .returnResult() .getResponseBody();
// Then: Response contains message from downstream service assertThat(response).contains("Message from message-service");
// And: Verify exchanged token was used // (Check logs or use a mock authorization server) }
private String generateTestToken(String audience) { // Generate a JWT with given audience for testing return JWT.create() .withSubject("baeldung") .withAudience(audience) .withIssuer("http://localhost:9001") .withExpiresAt(Instant.now().plusSeconds(300)) .sign(Algorithm.RSA256(publicKey, privateKey)); }}The Reason: What’s Actually Happening
Let me explain the flow with more detail:
Step 1: User logs in via browser┌─────────┐ ┌─────────────┐│ Browser │──(1) GET /────────▶│ Client App │└─────────┘ │ (port 8080) │ │ └──────┬──────┘ │ │ │ (2) Redirect to login │ ▼ │┌─────────────────┐ ││ Auth Server │ ││ (port 9001) │ │└────────┬────────┘ │ │ (3) User authenticates │ │ Token A issued │ │ aud: user-service │ ▼ │┌─────────┐ ││ Browser │◄─────────────────────────┘└────┬────┘ (4) Session established │ │ (5) GET /api/user/message ▼┌─────────────────────────────────────────────────────┐│ CLIENT APP ││ ││ @RegisteredOAuth2AuthorizedClient injects Token A ││ Token A has aud: user-service │└─────────────────────────────────────────────────────┘ │ │ (6) Call user-service with Token A ▼┌─────────────────────────────────────────────────────┐│ USER SERVICE (Resource Server) ││ ││ Validates Token A (aud: user-service) ✓ ││ Needs to call message-service ││ Token A has wrong audience for message-service! ││ ││ Token Exchange Request: ││ POST /oauth2/token ││ grant_type=urn:ietf:params:oauth:grant-type: ││ token-exchange ││ subject_token=Token_A ││ audience=message-service │└─────────────────────────────────────────────────────┘ │ │ (7) Exchange request ▼┌─────────────────────────────────────────────────────┐│ AUTH SERVER ││ ││ Validates Token A ││ Issues Token B: ││ sub: baeldung (SAME USER) ││ aud: message-service (NEW AUDIENCE) ││ scope: message.read (NEW SCOPE) ││ act: {sub: user-service} (ACTOR INFO) │└─────────────────────────────────────────────────────┘ │ │ (8) Return Token B ▼┌─────────────────────────────────────────────────────┐│ USER SERVICE ││ ││ Now calls message-service with Token B │└─────────────────────────────────────────────────────┘ │ │ (9) Call message-service with Token B ▼┌─────────────────────────────────────────────────────┐│ MESSAGE SERVICE ││ ││ Validates Token B (aud: message-service) ✓ ││ Processes request ││ Returns: "Hello baeldung!" │└─────────────────────────────────────────────────────┘Common Testing Mistakes
Mistake 1: Not Starting All Services
I often forgot to start the authorization server:
Error: Connection refused: localhost:9001Cause: Authorization server not runningFix: Start auth-server firstMistake 2: Wrong Port Configuration
Mixing up ports caused confusing errors:
# WRONG: Resource server port mismatchspring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9001 # Correct # But resource server on port 8082 instead of 8081I fixed by double-checking all port configurations:
# Client App (port 8080)server: port: 8080spring: security: oauth2: client: provider: messaging-client-oidc: issuer-uri: http://localhost:9001
# User Service (port 8081)server: port: 8081spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9001
# Message Service (port 8082)server: port: 8082spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9001Mistake 3: Not Checking Token Claims
I assumed token exchange worked without verifying claims:
# WRONG: Assuming it works because response comes back# RIGHT: Decode and compare token claimsI now always verify:
# Decode JWT payload (middle section between dots)echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .
# Check:# 1. aud claim changed to target service# 2. sub claim preserved (user identity)# 3. act claim present (actor information)Mistake 4: Using Wrong Grant Type
Initially I configured client credentials instead of token exchange:
# WRONG: This gets a new token, loses user identityspring: security: oauth2: client: registration: messaging-client: authorization-grant-type: client_credentials # This loses user identity!Fixed with correct grant type:
# CORRECT: Token exchange preserves user identityspring: security: oauth2: client: registration: messaging-client-oidc: authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchangeSummary
In this post, I demonstrated how to test OAuth 2.0 Token Exchange end-to-end in Spring Boot. The key verification points are:
- Start all services - Authorization server, resource server(s), and client app must all be running
- Log token claims - Add filters to decode and log JWT claims
- Verify audience change - The exchanged token should have a different
audclaim - Verify user identity preserved - The
subclaim should remain the same - Check actor claim - The
actclaim shows who performed the exchange
The test flow is: Login via browser -> Call protected endpoint -> Check logs for token claims -> Verify audience changed while subject preserved.
This end-to-end test validates that your token exchange configuration is working correctly across the entire OAuth 2.0 flow.
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:
- 👨💻 RFC 8693: OAuth 2.0 Token Exchange
- 👨💻 Spring Security OAuth 2.0 Client
- 👨💻 Spring Authorization Server
- 👨💻 Baeldung: OAuth2 Token Exchange in Spring Security
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments