Your API gateway validates a user’s access token, confirms the signature and audience, and forwards the request to a backend service. What does that backend service actually receive?
In a lot of architectures, the answer is one of two bad options. Either the gateway strips the token and the backend trusts anything arriving from the internal network, or the gateway forwards the original token verbatim to every service it touches. The first option means a single compromised service can impersonate any user to any other service. The second means a token minted for your public API is now sitting in the logs of six internal services, any one of which could replay it against the others.
OAuth 2.0 Token Exchange, standardized as RFC 8693, is the specified way out of this. It lets a party swap one token for another — narrower in scope, bound to a specific audience, and carrying an explicit record of who is acting on whose behalf. This article covers what the spec actually says, where the security boundaries are, and how to implement the pattern at the edge with a programmable gateway.
The Problem: Identity Stops at the Edge
Consider a typical request path. A user’s browser calls POST /orders. Your
gateway validates the JWT from your identity provider and proxies to the orders
service. The orders service calls the inventory service, which calls the pricing
service.
The user’s identity was verified exactly once, at the edge. Everything after that is a question of what the gateway chose to pass along.
Option one — the trusted network. The gateway drops the token and internal services accept unauthenticated calls from inside the perimeter. Every service becomes a confused deputy: it holds enough privilege to do anything, and it acts on instructions it cannot attribute to any particular user. Anyone who reaches the internal network inherits that privilege.
Option two — forward the original token. Now the orders service holds a token that is valid at the gateway, at inventory, and at pricing. Its blast radius is the union of everything that token can do. If the orders service is compromised — or just over-logs — the attacker has a credential good against the entire estate. The audience restriction that made the token safe at the edge means nothing once six services accept it.
Option three — custom headers. The gateway sets X-User-Id and moves on.
This works right up until any service is reachable by something other than the
gateway, at which point X-User-Id is an unauthenticated string that the caller
controls. There is no signature to verify and no expiry.
Token exchange gives you a fourth option: the gateway trades the inbound token for a new, cryptographically verifiable token that names the user, names the gateway as the actor, is audience-bound to exactly one downstream service, and carries only the scopes that service needs.
How RFC 8693 Works
Token exchange is not a new endpoint. It is a new grant type at the existing OAuth 2.0 token endpoint:
The three actors in an exchange
The spec describes three roles. The subject is the principal the token is about — usually your end user. The actor is the party doing something on the subject’s behalf — in our case, the gateway or an intermediate service. The authorization server validates the presented tokens and decides whether to issue a new one.
The token exchange request
A token exchange request presents one mandatory token and optionally a second:
The parameters that matter most:
subject_token(required) — the token representing the identity the new token should be about. This is the user’s inbound token.subject_token_type(required) — a URI identifying what kind of token that is (...:access_token,...:id_token,...:jwt, and others).actor_token— a token representing the party that will act on the subject’s behalf. Including it is what produces delegation semantics rather than impersonation.audienceand/orresource— where the issued token is meant to be used.resourceis a URI, and matches the semantics of RFC 8707 Resource Indicators.scope— the permissions being requested for the new token. This is the narrowing lever.requested_token_type— what kind of token you want back.
The response looks like a normal token response, with one addition:
issued_token_type is required, because the authorization server is allowed to
return a different token type than the one you asked for. Note also that
token_type can be the literal string N_A when the issued token is not
intended to be used as a bearer token — a detail that trips up client libraries
that assume every token response is bearer-shaped.
Delegation vs. impersonation
This distinction is the heart of the spec, and it is worth being precise about.
With impersonation, the issued token is indistinguishable from one the user
obtained directly. The downstream service sees sub: user-123 and has no way to
know that a gateway was involved. It is simple, and it destroys your audit
trail.
With delegation, the issued token keeps the user as the subject but adds an
act (actor) claim identifying who is acting:
The downstream service now knows both who the request is for and what is
carrying it. Nested delegation is expressible too — an act claim can itself
contain an act claim, recording a chain of intermediaries with the most recent
actor outermost.
The spec is strict about how that chain may be used: for access control
decisions, a consumer must consider only the token’s top-level claims and the
current actor. Prior actors in nested act claims are informational. That is
the right split — the chain is an audit record, not an authorization input.
Prefer delegation. The audit trail is the point.
The may_act claim and the authorization question
A token exchange request is a request, not a command. The authorization server
decides whether to honor it, and RFC 8693 gives it a claim to make that decision
with: may_act, embedded in the subject token, names the parties permitted
to act on that subject’s behalf.
Here is the part that gets missed. may_act and scope answer two different
questions:
may_actanswers “is this actor allowed to act for this user at all?” It is a binary gate, typically set at consent time.scopeon the exchange request answers “for this specific call, how much authority?”
A system that implements only may_act hands the actor the user’s full
authority every time. The act claim makes the delegation visible; only a
narrowed scope makes it limited. You need both — visibility and limitation
are separate controls.
One more caveat from the spec: performing a token exchange has no effect on the validity of the subject or actor token. The original token keeps working until it expires. Exchange is not a downgrade or a revocation, so do not treat the existence of a narrow child token as evidence that the broad parent token is gone.
The confused deputy risk
The reason to care about all of this is the confused deputy problem. A gateway is, by construction, a highly privileged intermediary: it holds or can obtain credentials to everything behind it, and it acts on instructions from the outside world.
If the gateway forwards a broadly-scoped token downstream, or authenticates to upstreams with a single omnipotent service credential, then any bug in request routing or path handling can be turned into unauthorized access. The attacker does not need to steal a credential — they just need to convince the deputy to use the one it already has, in a way it did not intend.
Token exchange narrows the deputy’s authority per request. The token attached
to a call to the inventory service is good for the inventory service, for one
user, for one scope, for five minutes. A routing bug that sends that request to
the billing service produces a 401, not a breach.
Implementing Token Exchange at a Zuplo Gateway
Zuplo does not ship a general-purpose “RFC 8693 endpoint” policy for REST APIs. What it gives you is the pieces: a validated user identity at the edge, a TypeScript policy engine that runs on every request, a cache, and a set of upstream credential policies. The exchange pattern is a short custom policy.
For MCP traffic there is a built-in path, covered in Cross App Access for MCP below.
Step 1: Establish identity at the edge
Everything downstream depends on the gateway having actually verified who the
caller is. The
OpenID JWT Authentication policy
validates the inbound token against your identity provider’s JWKS and populates
request.user:
After this policy runs, request.user.sub holds the subject and
request.user.data holds the remaining claims. That is your subject identity —
verified, not asserted.
Step 2: Exchange the token in a custom policy
A custom code inbound policy
receives the request before it is proxied and can return a modified
ZuploRequest. That is exactly the right hook for an exchange.
Create modules/token-exchange.ts:
Wire it up in policies.json:
Then attach validate-user-jwt followed by exchange-for-inventory to the
routes that proxy to the inventory service. Each upstream gets its own policy
instance with its own audience and scope, which is the whole point — the
narrowing is per-destination.
A few notes on the implementation. The cache key includes the audience, so a user hitting three different upstreams gets three different tokens rather than one shared one. The TTL is trimmed below the token’s own lifetime so you are never attaching a credential that expires mid-flight. And the original inbound token is replaced rather than forwarded alongside — the upstream should never see it.
Alternative: When your IdP does not support token exchange
Not every identity provider implements RFC 8693, and not every internal service needs a full IdP round trip on the hot path. A gateway-signed JWT is often the right answer. Treat this as a replacement for Step 2 rather than a step that follows it.
The JWT service plugin turns your gateway into its own token issuer, complete with a JWKS endpoint your internal services can validate against.
The JWT service plugin and the Upstream Zuplo JWT policy are enterprise features. Both are free to use on any plan for development, but production use requires an enterprise plan.
Enable it in zuplo.runtime.ts:
The plugin serves an OIDC discovery document and a JWKS at
/__zuplo/issuer/.well-known/openid-configuration and
/__zuplo/issuer/.well-known/jwks.json, so your backend services validate these
tokens with a standard JWKS-aware JWT library — no shared secret, no bespoke
header scheme. One gotcha: the plugin signs with EdDSA by default, which not
every library supports. On Node, use jose rather than jsonwebtoken, or set
the plugin’s algorithm option to RS256.
The
Upstream Zuplo JWT policy
then mints and attaches one of these tokens on every proxied request. Its
claimsFromUser option copies claims from the authenticated request.user into
the outbound token — each key is the claim name, each value a path into
request.user. Setting it also makes the policy require an authenticated user,
returning a 401 if request.user is absent:
This is not RFC 8693 — no authorization server is consulted, and the gateway is
asserting the user’s identity rather than having it re-verified. But it delivers
the properties that matter downstream: a signed, short-lived, audience-bound
token that carries verified user context, replacing the unauthenticated
X-User-Id header.
If you need to construct the token yourself — to build an act claim by hand,
say — JwtServicePlugin.signJwt is available in any policy or handler:
Related Patterns
Token exchange sits alongside several other credential patterns at the edge. It helps to know which problem each one solves.
Client credentials for service-to-service auth
When the upstream needs to know that the gateway is calling and does not care
which user triggered it, you do not need an exchange — you need a machine
credential. The
Upstream OAuth client credentials policy
fetches an access token from any OAuth 2.0 client-credentials endpoint and
attaches it to the proxied request, caching it until it nears expiry so you are
not paying a token round trip per call. If the token endpoint returns no
expires_in, the policy caches conservatively rather than indefinitely,
assuming a ten-minute lifetime.
This is the right tool for calls with no user in the picture. It is the wrong tool when a user is involved, because a shared service credential erases exactly the identity you were trying to propagate. Zuplo supports a range of upstream credential policies for GCP, Azure Entra ID, AWS SigV4, and Firebase alongside the generic OAuth one.
Audience binding with RFC 8707
Token exchange narrows who a token is for. resource and audience are what
make that narrowing enforceable, and
RFC 8707 Resource Indicators is the
spec that defines how a client asks for a token bound to a specific resource.
This matters far beyond microservices. In an MCP deployment, an agent that holds a token minted for one server should not be able to replay it against another server behind the same identity provider — and an issuer-only validation check will happily let it. We wrote about that failure mode in detail in Bind Every MCP Token to One Server.
Cross App Access for MCP
For MCP traffic specifically, Zuplo’s MCP Gateway includes Cross App Access (XAA), which runs a real RFC 8693 exchange for you rather than making you write one.
The
mcp-token-exchange-inbound policy
with authMode: "id-jag" makes the gateway act as the XAA requesting app: it
mints an identity assertion authorization grant from your identity provider via
token exchange, then redeems it at the upstream resource’s authorization server
for an access token. Neither the MCP client nor the upstream MCP server has to
implement any of it.
Two caveats worth knowing before you reach for it. ID-JAG mode is built on an active IETF draft rather than a finalized RFC, so the wire format may still move. And XAA requires a prior inbound browser login, since the gateway needs an identity assertion from your IdP to exchange in the first place — it is not a fit for a purely headless agent.
The same policy also covers the simpler cases — user-oauth for per-user
upstream credentials and shared-oauth for a gateway-wide grant. The broader
architectural argument for putting this translation layer at the gateway rather
than inside your MCP server is in
Decouple Agent Auth From Your MCP Server.
Best Practices and Common Pitfalls
Make downstream tokens short-lived. Five minutes is plenty for a token that exists only to cross one service boundary. The longer the lifetime, the more the exchange looks like the token-forwarding problem you were trying to solve. Our guide to token expiry goes deeper on picking lifetimes.
Always set an audience. A token with no audience restriction is a token that works everywhere, and an exchange that produces one has accomplished nothing. Validate the audience on the receiving end, too — issuing it is only half the control.
Narrow the scope, do not just record the actor. As covered in
the may_act section,
act gives you visibility and scope gives you limitation. Requesting the same broad
scope set on every exchange is a common way to build a system that looks
delegated and behaves like impersonation. If you are designing a scope taxonomy
from scratch, see our guide to
custom scopes in OAuth.
Cache exchanged tokens, but key them correctly. Key on subject and audience and scope. A cache keyed on user alone will hand a service the token minted for a different service — reintroducing the exact cross-audience replay the pattern prevents. The policy above can omit scope from its key only because scope is fixed per policy instance; if you make scope dynamic, it belongs in the key.
Do not exchange on every hop without thinking. Each exchange is a network call to your authorization server. In a deep call graph, exchanging at every layer can put the IdP on your critical path several times per request. A common compromise: exchange at the edge, then use gateway-signed JWTs for internal hops.
Handle exchange failures explicitly. If the authorization server rejects the
exchange — because may_act does not permit it, or the subject token has
expired — that is a meaningful authorization signal, not a transient error. Do
not fall back to forwarding the original token.
Remember that exchange does not revoke. The subject token remains valid after the exchange. If you need the original credential gone, you need actual revocation.
Where Token Exchange Fits
Token exchange is not the only tool for propagating identity, and it is not free. It adds a dependency on your authorization server, latency you have to cache around, and a scope taxonomy you have to design and maintain.
What it buys is the ability to say something precise about every internal request: this call is being made by the gateway, on behalf of user-123, against the inventory service, with read permission, for the next five minutes. Every clause in that sentence is a boundary an attacker has to cross. Compare that to a flat internal network, where the corresponding sentence is this call came from somewhere inside.
The gateway is the right place to enforce this because it is the only component that has both halves of the picture — the verified inbound identity and the knowledge of which upstream the request is headed to. Doing it in application code means every service reimplements it, and the one that gets it wrong is the one that gets exploited.
If you are building this out, the natural sequence is: validate identity at the
edge with a JWT policy, replace any unauthenticated X-User-Id headers with
signed gateway JWTs, then introduce real RFC 8693 exchange on the paths where
the extra round trip earns its keep — the ones touching your most sensitive
upstreams.
Ready to implement identity propagation at your gateway? Start with the OpenID JWT Authentication policy and the custom code inbound policy, or read up on upstream credentials to see the full set of options for authenticating to your backends.