Zuplo
API Authentication

Token Exchange and Identity Propagation at the API Gateway (RFC 8693 Explained)

Nate TottenNate Totten
August 15, 2026
15 min read

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.

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:

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

Terminalbash
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.
  • 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:

JSONjson
{
  "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:

JSONjson
{
  "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 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:

JSONjson
{
  "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 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:

JSONjson
{
  "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 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:

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

JSONjson
{
  "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:

TypeScriptts
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 });
}

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.

Frequently asked questions

Common questions, answered.

Try Zuplo free

Try the platform behind this guide

Zuplo is a developer-first API gateway. Deploy your first API in minutes — no credit card required.

  • 100K requests/mo free
  • GitOps deploys
  • 300+ edge locations

Try Zuplo free — 100K requests/mo

Start free