What Is OAuth 2.0 Token Exchange and When Should You Use It?
Purpose
This post explains OAuth 2.0 Token Exchange (RFC 8693) and when to use it in microservices architectures.
Problem
I was building a microservices application where a user-service needed to call a message-service on behalf of an authenticated user. I thought I could just pass the same JWT token from service to service. I was wrong.
Here’s what my token looked like:
{ "sub": "baeldung", "aud": "user-service", "scope": ["user.read", "user.write"]}When user-service tried to call message-service with this token, I got:
HTTP 401 Unauthorized{ "error": "invalid_token", "error_description": "Invalid audience claim. Expected 'message-service', got 'user-service'"}I was confused. The token was valid. The user was authenticated. Why did message-service reject it?
The Mistake I Made
I made a common mistake: I assumed I could reuse the same JWT across all microservices. This violates the OAuth 2.0 audience (aud) claim restriction.
The aud claim specifies WHO the token is intended for. A token with aud: user-service should only be accepted by user-service. Passing it to message-service is a security violation.
Let me show why this matters with a comparison:
+------------------+-------------------------------+| Scenario | What Happens |+------------------+-------------------------------+| Same token reuse | Security violation - token || across services | contains wrong audience |+------------------+-------------------------------+| Client creds | Works but LOSES user identity || for each service | Service acts as itself, not || | on behalf of user |+------------------+-------------------------------+| Token exchange | Gets new token with correct || (RFC 8693) | audience, PRESERVES user || | identity in 'sub' claim |+------------------+-------------------------------+Environment
- Spring Boot 3.3+
- Spring Security 6.3+
- OAuth 2.0 Authorization Server (Keycloak/Auth0/Okta)
- JWT tokens
Solution: OAuth 2.0 Token Exchange
OAuth 2.0 Token Exchange (RFC 8693) is a standardized grant type that lets you exchange one access token for another. The key benefit: the new token has a different audience but preserves the original user identity.
How Token Exchange Works
Here’s the flow I implemented:
+-------------+ +-------------+ +-------------+ +-------------+| Client | | User | | Auth | | Message || App | | Service | | Server | | Service |+------+------+ +------+------+ +------+------+ +------------+ | | | | | 1. Login | | | |------------------->| | | | | | | | 2. Token A | | | |<-------------------| | | | (aud: user-svc) | | | | | | | | 3. API Call | | | |------------------->| | | | | | | | | 4. Exchange | | | | request | | | |------------------>| | | | | | | | 5. Token B | | | |<------------------| | | | (aud: msg-svc) | | | | | | | | 6. Call msg-svc | | | |---------------------------------->| | | | | | | 7. Response | | | |<----------------------------------| | | | | | 8. Response | | | |<-------------------| | | | | | |Step-by-Step Breakdown
Step 1-2: User authenticates and receives Token A
Token A has audience user-service. This token is only valid for user-service.
Step 3-4: user-service needs to call message-service
Instead of reusing Token A, user-service sends a token exchange request to the authorization server.
Step 5: Authorization server issues Token B
Token B has:
- New audience:
message-service - Same subject:
baeldung(user identity preserved) - New scope:
message.read(appropriate for downstream service)
Here’s what Token B looks like:
{ "sub": "baeldung", "aud": "message-service", "scope": ["message.read"], "act": { "sub": "user-service" }}The act (actor) claim shows that user-service acted on behalf of the user.
Step 6-8: Call succeeds
message-service accepts Token B because it has the correct audience. The user’s identity is preserved.
The Token Exchange Request
Here’s the actual HTTP request I made to exchange the token:
POST /oauth2/token HTTP/1.1Host: auth.example.comContent-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=eyJhbGciOiJSUzI1NiIs...&subject_token_type=urn:ietf:params:oauth:token-type:jwt&audience=message-service&scope=message.readKey parameters:
+-------------------+--------------------------------------------+| Parameter | Meaning |+-------------------+--------------------------------------------+| grant_type | MUST be urn:ietf:params:oauth: || | grant-type:token-exchange |+-------------------+--------------------------------------------+| subject_token | The incoming token you want to exchange |+-------------------+--------------------------------------------+| subject_token_type| Usually urn:ietf:params:oauth: || | token-type:jwt for JWTs |+-------------------+--------------------------------------------+| audience | The target service you want to call |+-------------------+--------------------------------------------+| scope | Permissions needed for downstream service |+-------------------+--------------------------------------------+Why Not Use Client Credentials?
I initially tried using client credentials flow for service-to-service calls. Here’s why that was wrong:
+------------------+----------------------+----------------------+| Aspect | Client Credentials | Token Exchange |+------------------+----------------------+----------------------+| User Identity | LOST | PRESERVED || | (service acts as | (sub claim kept) || | itself) | |+------------------+----------------------+----------------------+| Authorization | Service permissions | User permissions || | only | + delegation |+------------------+----------------------+----------------------+| Audit Trail | "service X called" | "service X called on || | | behalf of user Y" |+------------------+----------------------+----------------------+| Use Case | Background jobs, | User-initiated || | scheduled tasks | requests |+------------------+----------------------+----------------------+When a user requests their messages, I need to know WHO requested them. Client credentials loses that information. Token exchange preserves it.
When to Use Token Exchange
I use token exchange in these scenarios:
+----------------------------------+----------------------------------+| Use Token Exchange When... | Don't Use When... |+----------------------------------+----------------------------------+| User initiates action that | Background batch jobs run by || requires multiple services | the service itself |+----------------------------------+----------------------------------+| You need audit trail showing | Service-to-service calls that || which user triggered what | don't involve user context |+----------------------------------+----------------------------------+| Downstream service has | You control all services and || different audience requirement | trust boundary is clear |+----------------------------------+----------------------------------+| Authorization server supports | Auth server doesn't support || RFC 8693 | RFC 8693 |+----------------------------------+----------------------------------+Common Mistakes I See
Mistake 1: Disabling Audience Validation
I’ve seen developers disable audience validation to make tokens work across services:
# DANGEROUS - Do not do thisspring: security: oauth2: resourceserver: jwt: # This disables audience validation! audiences: []This defeats the purpose of audience claims. Anyone with a valid token from ANY service can access ALL services.
Mistake 2: Passing User ID in Headers
Instead of token exchange, some pass user ID in custom headers:
GET /api/messages HTTP/1.1X-User-ID: baeldungAuthorization: Bearer <service-token>Problems with this approach:
- No cryptographic proof of user identity
- Headers can be spoofed
- No standard way to validate
- Breaks OAuth 2.0 security model
Mistake 3: Using the Same Token Everywhere
The original mistake I made. It works in development but fails in production because:
- Violates OAuth 2.0 best practices
- Security audits will flag it
- Cannot track which service accessed what
- Token compromise affects all services
Implementation with Spring Security 6.3
Spring Security 6.3 added first-class support for token exchange. Here’s how I configured it:
spring: security: oauth2: client: registration: message-service: authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange client-id: user-service client-secret: ${CLIENT_SECRET} scope: message.read provider: message-service: token-uri: https://auth.example.com/oauth2/tokenThen in my service, I use OAuth2AuthorizedClientManager:
@Servicepublic class TokenExchangeService {
private final OAuth2AuthorizedClientManager clientManager;
public String exchangeToken(String incomingToken) { OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest .withClientRegistrationId("message-service") .principal(createPrincipal(incomingToken)) .attributes(attrs -> { attrs.put(OAuth2AuthorizationContext.TOKEN_EXCHANGE_SUBJECT_TOKEN, incomingToken); attrs.put(OAuth2AuthorizationContext.TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE, "urn:ietf:params:oauth:token-type:jwt"); }) .build();
OAuth2AuthorizedClient client = clientManager.authorize(request); return client.getAccessToken().getTokenValue(); }}Comparison: Token Exchange vs Other Patterns
Here’s how token exchange compares to other common patterns:
+-------------------+------------+-------------------+------------+| Pattern | User | Service Identity | Standard || | Identity | Preserved | |+-------------------+------------+-------------------+------------+| Same token reuse | YES | NO | NO || (anti-pattern) | | | |+-------------------+------------+-------------------+------------+| Client Credentials| NO | YES | YES |+-------------------+------------+-------------------+------------+| On-Behalf-Of | YES | YES | Microsoft || (OBO) | | | specific |+-------------------+------------+-------------------+------------+| Token Exchange | YES | YES | YES || (RFC 8693) | | | (RFC) |+-------------------+------------+-------------------+------------+| Token Relay | YES | NO | Spring || (Gateway) | | | specific |+-------------------+------------+-------------------+------------+Summary
In this post, I explained OAuth 2.0 Token Exchange (RFC 8693) and when to use it. The key points are:
- The problem: JWTs have audience restrictions that prevent reuse across services
- The wrong approach: Reusing tokens or using client credentials (loses user identity)
- The solution: Token exchange gets a new token with correct audience while preserving user identity
- When to use: User-initiated actions spanning multiple services
- When not to use: Background jobs or service-only operations
Token exchange is the right tool when you need to maintain user identity across service boundaries while respecting OAuth 2.0 security constraints.
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 Resource Server
- 👨💻 Baeldung: OAuth2 Token Exchange
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments