---
title: "Token Exchange and Identity Propagation at the API Gateway (RFC 8693 Explained)"
description: "How OAuth 2.0 Token Exchange (RFC 8693) propagates verified user identity to downstream microservices, and how to implement the pattern at your API gateway."
canonicalUrl: "https://zuplo.com/learning-center/oauth2-token-exchange-identity-propagation"
pageType: "learning-center"
authors: "nate"
tags: "API Authentication"
image: "https://zuplo.com/og?text=Token%20Exchange%20and%20Identity%20Propagation%20at%20the%20API%20Gateway"
---
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](https://www.rfc-editor.org/rfc/rfc8693), 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:

```
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
```

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

```bash
curl -X POST https://idp.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=eyJhbGciOi..." \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "actor_token=eyJhbGciOi..." \
  -d "actor_token_type=urn:ietf:params:oauth:token-type:jwt" \
  -d "audience=https://inventory.internal" \
  -d "scope=inventory:read" \
  -d "client_id=api-gateway" \
  -d "client_secret=..."
```

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.
- **`audience`** and/or **`resource`** — where the issued token is meant to be
  used. `resource` is a URI, and matches the semantics of
  [RFC 8707 Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707).
- **`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:

```json
{
  "access_token": "eyJhbGciOi...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "inventory:read"
}
```

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

```json
{
  "iss": "https://idp.example.com",
  "sub": "user-123",
  "aud": "https://inventory.internal",
  "scope": "inventory:read",
  "exp": 1755250000,
  "act": {
    "sub": "api-gateway",
    "iss": "https://idp.example.com"
  }
}
```

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_act` answers *"is this actor allowed to act for this user at all?"* It is
  a binary gate, typically set at consent time.
- `scope` on 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](#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](https://zuplo.com/docs/policies/open-id-jwt-auth-inbound)
validates the inbound token against your identity provider's JWKS and populates
[`request.user`](https://zuplo.com/docs/programmable-api/request-user):

```json
{
  "name": "validate-user-jwt",
  "policyType": "open-id-jwt-auth-inbound",
  "handler": {
    "export": "OpenIdJwtInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "issuer": "https://idp.example.com/",
      "audience": "https://api.example.com",
      "jwkUrl": "https://idp.example.com/.well-known/jwks.json"
    }
  }
}
```

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](https://zuplo.com/docs/policies/custom-code-inbound)
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`:

```ts
import {
  ZuploContext,
  ZuploRequest,
  MemoryZoneReadThroughCache,
  HttpProblems,
  ConfigurationError,
  environment,
} from "@zuplo/runtime";

interface TokenExchangeOptions {
  /** The authorization server's token endpoint. */
  tokenUrl: string;
  /** The downstream service this token is for. */
  audience: string;
  /** Scopes to request for the downstream token — keep these narrow. */
  scope: string;
}

interface TokenResponse {
  access_token: string;
  issued_token_type: string;
  expires_in?: number;
}

const TOKEN_TYPE_ACCESS = "urn:ietf:params:oauth:token-type:access_token";

// `environment` values are typed `string | undefined`. Validate once at module
// scope so a missing secret fails at deploy time, not on a live request.
const CLIENT_ID = environment.EXCHANGE_CLIENT_ID;
const CLIENT_SECRET = environment.EXCHANGE_CLIENT_SECRET;

if (!CLIENT_ID || !CLIENT_SECRET) {
  throw new ConfigurationError(
    "EXCHANGE_CLIENT_ID and EXCHANGE_CLIENT_SECRET must be set.",
  );
}

export default async function tokenExchange(
  request: ZuploRequest,
  context: ZuploContext,
  options: TokenExchangeOptions,
  policyName: string,
): Promise<ZuploRequest | Response> {
  if (!request.user) {
    return HttpProblems.unauthorized(request, context);
  }

  const inboundToken = request.headers
    .get("authorization")
    ?.replace(/^Bearer /i, "");

  if (!inboundToken) {
    return HttpProblems.unauthorized(request, context, {
      detail: "No bearer token to exchange.",
    });
  }

  // Cache exchanged tokens per (user, audience) so a burst of requests
  // from one user does not become a burst of calls to the IdP.
  const cache = new MemoryZoneReadThroughCache<string>(
    "token-exchange",
    context,
  );
  const cacheKey = `${request.user.sub}:${options.audience}`;

  let downstreamToken = await cache.get(cacheKey);

  if (!downstreamToken) {
    const body = new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
      subject_token: inboundToken,
      subject_token_type: TOKEN_TYPE_ACCESS,
      requested_token_type: TOKEN_TYPE_ACCESS,
      audience: options.audience,
      scope: options.scope,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    });

    const response = await fetch(options.tokenUrl, {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body,
    });

    if (!response.ok) {
      context.log.error("Token exchange failed", {
        status: response.status,
        policyName,
      });
      return HttpProblems.internalServerError(request, context, {
        detail: "Could not obtain a downstream credential.",
      });
    }

    const token = (await response.json()) as TokenResponse;
    downstreamToken = token.access_token;

    // Expire the cache entry before the token itself does.
    const ttl = Math.max((token.expires_in ?? 300) - 30, 30);
    cache.put(cacheKey, downstreamToken, ttl);
  }

  const headers = new Headers(request.headers);
  headers.set("authorization", `Bearer ${downstreamToken}`);
  return new ZuploRequest(request, { headers });
}
```

Wire it up in `policies.json`:

```json
{
  "name": "exchange-for-inventory",
  "policyType": "custom-code-inbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/token-exchange)",
    "options": {
      "tokenUrl": "https://idp.example.com/oauth/token",
      "audience": "https://inventory.internal",
      "scope": "inventory:read"
    }
  }
}
```

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](https://zuplo.com/docs/programmable-api/jwt-service-plugin)
turns your gateway into its own token issuer, complete with a JWKS endpoint your
internal services can validate against.

:::info

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

```ts
import { RuntimeExtensions, JwtServicePlugin } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addPlugin(new JwtServicePlugin({ expiresIn: "5m" }));
}
```

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](https://zuplo.com/docs/policies/upstream-zuplo-jwt-auth-inbound)
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:

```json
{
  "name": "gateway-jwt-for-inventory",
  "policyType": "upstream-zuplo-jwt-auth-inbound",
  "handler": {
    "export": "UpstreamZuploJwtAuthInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "audience": "https://inventory.internal",
      "expiresIn": 300,
      "claimsFromUser": {
        "sub": "sub",
        "email": "data.email",
        "org": "data.org_id"
      },
      "additionalClaims": {
        "scope": "inventory:read"
      },
      "headerName": "Authorization",
      "tokenPrefix": "Bearer"
    }
  }
}
```

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:

```ts
import { ZuploRequest, ZuploContext, JwtServicePlugin } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const jwt = await JwtServicePlugin.signJwt({
    subject: request.user?.sub ?? "api-gateway",
    audience: "https://inventory.internal",
  });

  const headers = new Headers(request.headers);
  headers.set("Authorization", `Bearer ${jwt}`);
  return new ZuploRequest(request, { headers });
}
```

## 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](https://zuplo.com/docs/policies/upstream-oauth-client-credentials-inbound)
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](https://zuplo.com/docs/concepts/upstream-credentials)
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](https://www.rfc-editor.org/rfc/rfc8707) 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](https://zuplo.com/blog/bind-mcp-tokens-to-one-server).

### Cross App Access for MCP

For MCP traffic specifically, Zuplo's MCP Gateway includes
[Cross App Access (XAA)](https://zuplo.com/docs/mcp-gateway/cross-app-access/policy-reference),
which runs a real RFC 8693 exchange for you rather than making you write one.

The
[`mcp-token-exchange-inbound` policy](https://zuplo.com/docs/policies/mcp-token-exchange-inbound)
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](https://zuplo.com/blog/decouple-agent-auth-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](https://zuplo.com/learning-center/token-expiry-best-practices)
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](#the-may_act-claim-and-the-authorization-question),
`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](https://zuplo.com/learning-center/custom-scopes-in-oauth-guide).

**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](https://zuplo.com/docs/policies/open-id-jwt-auth-inbound)
and the
[custom code inbound policy](https://zuplo.com/docs/policies/custom-code-inbound),
or read up on
[upstream credentials](https://zuplo.com/docs/concepts/upstream-credentials) to
see the full set of options for authenticating to your backends.