Skip to content

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:

original-jwt.json
{
"sub": "baeldung",
"aud": "user-service",
"scope": ["user.read", "user.write"]
}

When user-service tried to call message-service with this token, I got:

error-response.txt
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:

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

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

exchanged-jwt.json
{
"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:

token-exchange-request.http
POST /oauth2/token HTTP/1.1
Host: auth.example.com
Content-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.read

Key parameters:

exchange-parameters.txt
+-------------------+--------------------------------------------+
| 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:

client-creds-vs-token-exchange.txt
+------------------+----------------------+----------------------+
| 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-cases.txt
+----------------------------------+----------------------------------+
| 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:

bad-practice.txt
# DANGEROUS - Do not do this
spring:
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:

anti-pattern.txt
GET /api/messages HTTP/1.1
X-User-ID: baeldung
Authorization: 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:

application.yml
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/token

Then in my service, I use OAuth2AuthorizedClientManager:

TokenExchangeService.java
@Service
public 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-comparison.txt
+-------------------+------------+-------------------+------------+
| 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:

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

Comments