Skip to content

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:

service-architecture.txt
+-------------------+ +------------------+ +-------------------+
| Client App | | User Service | | Message Service |
| (port 8080) | | (port 8081) | | (port 8082) |
+-------------------+ +------------------+ +-------------------+
| | |
| | |
+---------> Authorization Server (port 9001) <-----+

But when I tested:

test-attempt.txt
# 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:

application-logs.txt
2026-03-26 10:15:23 INFO [nio-8080-exec-1] c.e.c.ClientController : Calling resource server
2026-03-26 10:15:23 INFO [nio-8081-exec-1] c.e.u.UserController : Processing request
2026-03-26 10:15:23 DEBUG [nio-8081-exec-1] o.s.s.o.a.JwtAuthenticationProvider : Authenticated principal

The logs showed requests were processed, but nothing about token exchange.

Attempt 3: Debug Mode

I enabled debug logging:

src/main/resources/application.yml
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE

Now I got more details:

debug-logs.txt
2026-03-26 10:20:15 TRACE [nio-8081-exec-1] o.s.s.o.a.AuthenticatedPrincipalOAuth2AuthorizedClientRepository : Loading authorized client
2026-03-26 10:20:15 TRACE [nio-8081-exec-1] o.s.s.o.a.OAuth2AuthorizedClientManager : Authorizing client
2026-03-26 10:20:15 DEBUG [nio-8081-exec-1] o.s.s.o.a.DefaultOAuth2TokenExchangeAuthorizedClientProvider : Exchanging token

This 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:

start-services.sh
# Terminal 1: Authorization Server
cd 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 Application
cd client-app && java -jar target/client-app.jar
# Should see: Started ClientApplication on port 8080

I verified all services were up:

verify-services.sh
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:

src/main/java/com/example/filter/TokenLoggingFilter.java
@Component
public 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:

browser-test-steps.txt
1. Navigate to: http://localhost:8080
2. Browser redirects to: http://localhost:9001/login
3. Login with: username=baeldung, password=password
4. After successful login, redirected back to: http://localhost:8080
5. Browser now has session with client app

In the authorization server logs, I saw:

auth-server-login-log.txt
2026-03-26 10:25:30 INFO [nio-9001-exec-1] o.s.s.w.a.UsernamePasswordAuthenticationFilter : User authenticated: baeldung
2026-03-26 10:25:30 INFO [nio-9001-exec-1] o.s.s.a.server.OAuth2AuthorizationServer : Authorization code issued

Step 4: Trigger Token Exchange

I clicked the link that calls the protected endpoint requiring token exchange:

src/main/java/com/example/client/ClientController.java
@RestController
public 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):

original-token-claims.json
{
"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):

exchanged-token-claims.json
{
"sub": "baeldung",
"aud": "message-service",
"scope": ["message.read"],
"iss": "http://localhost:9001",
"exp": 1709234667,
"iat": 1709231067,
"act": {
"sub": "user-service"
}
}

Key observations:

token-comparison.txt
+------------------+----------------------+------------------------+
| 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:

manual-test.sh
# 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 token
curl -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 token
echo "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:

src/test/java/com/example/TokenExchangeIntegrationTest.java
@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:

detailed-flow.txt
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:

missing-auth-server.txt
Error: Connection refused: localhost:9001
Cause: Authorization server not running
Fix: Start auth-server first

Mistake 2: Wrong Port Configuration

Mixing up ports caused confusing errors:

wrong-config.yml
# WRONG: Resource server port mismatch
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9001 # Correct
# But resource server on port 8082 instead of 8081

I fixed by double-checking all port configurations:

correct-config.yml
# Client App (port 8080)
server:
port: 8080
spring:
security:
oauth2:
client:
provider:
messaging-client-oidc:
issuer-uri: http://localhost:9001
# User Service (port 8081)
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9001
# Message Service (port 8082)
server:
port: 8082
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9001

Mistake 3: Not Checking Token Claims

I assumed token exchange worked without verifying claims:

verification-mistake.txt
# WRONG: Assuming it works because response comes back
# RIGHT: Decode and compare token claims

I now always verify:

verify-claims.sh
# 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-grant-type.yml
# WRONG: This gets a new token, loses user identity
spring:
security:
oauth2:
client:
registration:
messaging-client:
authorization-grant-type: client_credentials
# This loses user identity!

Fixed with correct grant type:

correct-grant-type.yml
# CORRECT: Token exchange preserves user identity
spring:
security:
oauth2:
client:
registration:
messaging-client-oidc:
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange

Summary

In this post, I demonstrated how to test OAuth 2.0 Token Exchange end-to-end in Spring Boot. The key verification points are:

  1. Start all services - Authorization server, resource server(s), and client app must all be running
  2. Log token claims - Add filters to decode and log JWT claims
  3. Verify audience change - The exchanged token should have a different aud claim
  4. Verify user identity preserved - The sub claim should remain the same
  5. Check actor claim - The act claim 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments