Token exchange and OBO flows for downstream APIs as the signed-in user
For developers building an API or backend that must call another API using the signed-in user’s identity, not a shared service credential. This guide explains token exchange and on-behalf-of flows concretely, shows where they fit in a request path, and gives you enough detail to decide whether to implement them or avoid the complexity.
TL;DR — Use token exchange / on-behalf-of (OBO) when your API receives a user token and must call a downstream API with that same user’s permissions, but the downstream API expects a token minted for itself, not the original audience. The most common fix is: validate the incoming access token, exchange it at your identity provider for a new access token whose
audis the downstream API, then send that token downstream. Reading time: ~7 min
What it is and where it sits
Token exchange and OBO flows solve a very specific problem in IAM-heavy systems: a user signs in to a frontend, the frontend calls your API, and your API then needs to call another API as that same user. The original access token usually cannot just be forwarded because it was minted for your API, not for the downstream one.
In other words, this sits between:
- the token your frontend got for calling your API, and
- the token your API needs for calling another protected resource.
What it replaces: the bad old pattern of either forwarding the wrong token downstream or using a shared service credential and losing user context.
Where it lives in a typical request flow:
- Frontend gets an access token for
api-a. - Frontend calls
api-awithAuthorization: Bearer .... api-avalidates that token.api-aexchanges it with the identity provider for a new token forapi-b.api-acallsapi-bwith the exchanged token.api-bauthorizes based on the user identity and delegated scopes in that new token.
View as text
[Browser SPA] ---- 1. bearer token for api-a ----> [API A] [Browser SPA] <---- 0. sign-in via OIDC/OAuth ----> [IdP] [API A] ---- 2. token exchange / OBO ----> [IdP] [API A] <---- 3. access token for api-b ---- [IdP] [API A] ---- 4. Bearer token(aud=api-b) ----> [API B] [API B] ---- 5. data for signed-in user ----> [API A]
From an architecture perspective, this is an IAM boundary-management tool. It lets each API enforce audience and scope correctly without forcing the frontend to request every possible downstream token itself.
The key architectural point experienced developers care about: API A becomes both a resource server and an OAuth client. It must validate incoming tokens and also securely call the token endpoint.
How it actually works
Walk one realistic example through the system.
Scenario:
- A React SPA calls
orders-api. orders-apineeds to callprofile-apito fetch the signed-in user’s shipping preferences.- The user token sent by the SPA has
aud=orders-api. profile-apirejects tokens unlessaud=profile-api.
Step 1: Frontend calls API A with a user token
The SPA gets an access token from the IdP for orders-api and sends it:
curl -i https://orders.example.com/v1/orders/123 \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'
orders-api validates signature, issuer, expiry, and audience. If you accidentally forward this token to profile-api, a healthy downstream API should reject it.
Typical failure shape:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The audience 'orders-api' is not valid for this resource"
Content-Type: application/json
{"error":"invalid_token","error_description":"audience mismatch"}
That rejection is good. It means your audience boundaries are working.
Step 2: API A exchanges the token
orders-api sends a back-channel POST to the IdP token endpoint. In generic OAuth 2.0 Token Exchange terms, it sends:
grant_type=urn:ietf:params:oauth:grant-type:token-exchangesubject_token=<incoming user access token>subject_token_type=urn:ietf:params:oauth:token-type:access_tokenaudience=profile-api- its own client authentication, typically
client_secret_basic,private_key_jwt, or mTLS
Example request:
curl -sS https://idp.example.com/oauth2/token \
-u 'orders-api-client:super-secret' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...' \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'audience=profile-api'
Success response shape:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 300,
"scope": "profile.read"
}
In some ecosystems this is called OBO rather than token exchange, but the operational idea is the same: API A presents proof of the user token it received and asks for a downstream token for API B.
Step 3: API A calls API B with the exchanged token
Now orders-api calls profile-api:
curl -i https://profile.example.com/v1/me/preferences \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ...'
profile-api validates:
issmatches the IdPaud=profile-api- token not expired
- delegated scope includes
profile.read - optional actor / delegation claims if your IdP emits them
If all is well, profile-api returns user-specific data. orders-api combines that with order data and returns the result to the SPA.
Step 4: Cache carefully
Do not exchange on every single downstream call if you can avoid it. Cache exchanged tokens keyed by at least:
- user subject (
sub) - downstream audience
- scope set
- tenant if you are multi-tenant
Set cache TTL to the token expiry minus a safety margin, for example 30 seconds.
Failure modes you will actually hit
Wrong audience:
{
"error": "invalid_target",
"error_description": "Requested audience is not allowed"
}
Missing delegated permission / scope:
{
"error": "invalid_scope",
"error_description": "Scope 'profile.read' is not granted to this client"
}
Trying OBO with the wrong kind of token, such as an ID token instead of an access token:
{
"error": "invalid_request",
"error_description": "subject_token_type is invalid or token is not exchangeable"
}
Downstream API still sees the wrong audience because you accidentally forwarded the original token:
curl -I https://profile.example.com/v1/me/preferences \
-H 'Authorization: Bearer eyJ...orders-api-token...'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="audience mismatch"
When to use it (and when not to)
Use it when you need delegated user context across API hops and each API enforces its own audience.
| Scenario | Recommendation |
|---|---|
| API A must call API B with the signed-in user’s permissions | Use token exchange / OBO |
| API A calls API B for app-level work, same permissions for every user | Use client credentials, not OBO |
| Frontend can call API B directly and CORS/network policy allows it | Prefer direct frontend-to-API-B call; skip middle-hop exchange |
| You only need user identity for logging, not authorization | Pass user ID as internal metadata after validating at API A; do not build OBO just for tracing |
| API A and API B are really one trust boundary and can be merged | Consider collapsing the hop instead of adding IAM complexity |
| Downstream API cannot validate delegated tokens from your IdP | Do not use OBO until that trust relationship exists |
You probably do not need this if:
- the downstream call is purely machine-to-machine,
- the frontend can request the downstream token directly without exposing secrets,
- or your “downstream API” is just an internal library call that should not be a separate OAuth resource at all.
Trade-offs
Every benefit here has a cost.
- Better least-privilege between APIs
- Cost: more IdP configuration, more moving parts, more ways to get
aud/scope wrong.
- Cost: more IdP configuration, more moving parts, more ways to get
- Preserves user context end to end
- Cost: more token handling code, more sensitive logs to scrub, more support burden when a user-specific permission breaks only one path.
- Lets downstream APIs authorize independently
- Cost: extra network hop to the token endpoint, usually 50-300 ms depending on topology and IdP load.
- Avoids sharing broad service credentials
- Cost: token caching, refresh strategy, and retry logic become your responsibility.
- Cleaner audit trail of “user X via API A called API B”
- Cost: vendor-specific claim shapes can create lock-in even when the core pattern is standard.
Operationally, the biggest hidden cost is incident diagnosis. A 401 could mean bad client auth at the token endpoint, wrong audience, missing consent, expired incoming token, clock skew, or a downstream trust config mismatch.
In practice
Example 1: Exchange a token with curl
TOKEN_ENDPOINT='https://idp.example.com/oauth2/token'
SUBJECT_TOKEN='eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'
CLIENT_ID='orders-api-client'
CLIENT_SECRET='replace-me'
curl -sS "$TOKEN_ENDPOINT" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'audience=profile-api' \
--data-urlencode 'scope=profile.read'
This performs the back-channel exchange from API A to the IdP. Gotcha: many teams accidentally send an ID token here because it is easier to inspect in the browser; OBO/token exchange normally expects an access token, and the IdP will reject the request or mint the wrong claims.
Example 2: Node.js API middleware and downstream call
import express from "express";
import jwt from "jsonwebtoken";
const app = express();
async function exchangeToken(subjectToken) {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: subjectToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
audience: "profile-api",
scope: "profile.read"
});
const basic = Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
).toString("base64");
const res = await fetch("https://idp.example.com/oauth2/token", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${basic}`
},
body
});
if (!res.ok) {
const text = await res.text();
throw new Error(`token exchange failed: ${res.status} ${text}`);
}
return res.json();
}
app.get("/v1/orders/:id", async (req, res) => {
const auth = req.header("authorization") || "";
const incoming = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!incoming) return res.status(401).json({ error: "missing bearer token" });
const decoded = jwt.decode(incoming, { complete: false });
if (!decoded || decoded.aud !== "orders-api") {
return res.status(401).json({ error: "invalid audience for this API" });
}
const exchanged = await exchangeToken(incoming);
const profileRes = await fetch("https://profile.example.com/v1/me/preferences", {
headers: { authorization: `Bearer ${exchanged.access_token}` }
});
if (!profileRes.ok) {
const text = await profileRes.text();
return res.status(502).json({ error: "downstream_failed", detail: text });
}
const prefs = await profileRes.json();
res.json({ orderId: req.params.id, shippingPreference: prefs.shippingPreference });
});
app.listen(3000);
This shows the shape of an API acting as both resource server and OAuth client. Gotcha: jwt.decode() does not validate signatures; in real code, validate with JWKS, issuer, audience, expiry, and acceptable algorithms before trusting any claim.
⚠️ Do not log raw bearer tokens in application logs, reverse proxy logs, or error trackers. A single leaked access token is usually enough to impersonate a user until expiry.
Example 3: Minimal token cache key design
{
"cache_key": "sub:248289761001|aud:profile-api|scope:profile.read|tenant:acme",
"expires_at_unix": 1793365200,
"refresh_skew_seconds": 30
}
This is the minimum useful cache shape for exchanged tokens. Gotcha: if you omit tenant or scope from the key in a multi-tenant system, you can return the wrong delegated token and create a cross-tenant authorization bug.
Further reading
- RFC 8693: OAuth 2.0 Token Exchange
- RFC 6749: The OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0
- OAuth 2.0 Security Best Current Practice
- The "Authorization" section of the MDN HTTP docs
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI