OAuth & OIDC

OAuth Token Exchange: Delegation, Impersonation, and Service-to-Service Auth

RFC 8693 explained — how token exchange solves the confused deputy problem in microservices, the difference between delegation and impersonation, act and may_act claims, and when a simpler answer is better.

Emilian GheoneaJuly 23, 20267 min read

You have a service mesh. A request arrives at your API gateway carrying a user's access token. The gateway calls the orders service, which calls the inventory service, which calls a third-party shipping API. What token does each hop present?

Every answer people reach for first is wrong in an instructive way, and the standard answer — RFC 8693 token exchange — is a mechanism most teams have never heard of despite having the problem it solves.

The three wrong answers

Forward the user's token everywhere. Simple, and it makes every downstream service a confused deputy waiting to happen. The inventory service now holds a token with the user's full scope set, valid at every service in your system. If the inventory service is compromised, or simply has a logging bug that writes headers to disk, the attacker has a credential that works against billing, admin, everything. You have also made the token's audience meaningless: if every service accepts a token minted for the gateway, then no service is actually verifying who the token was for, which is the check that stops a token stolen from one context being replayed in another.

Give each service a static service account. The inventory service authenticates as itself with a long-lived credential. This solves the blast radius problem and destroys something more valuable: the identity of the end user. The inventory service now sees "the orders service asked for this" and has no idea on whose behalf. Every authorization decision downstream collapses to "is this service allowed," which means the orders service must correctly enforce every user-level rule for every downstream system — and any bug there is a full authorization bypass. Your audit log records that a service did something, which is not what an auditor wants to read.

Pass the user ID in a header. X-User-Id: dana alongside the service credential. This restores the user identity and makes it unauthenticated: any service that can reach another service can claim to be acting for anyone. It works fine until the first internal compromise or SSRF, at which point it is a complete authorization bypass with no cryptographic barrier at all.

What you actually want is a token that carries both identities — the user and the service acting for them — scoped to exactly one downstream audience, and short-lived. That is what token exchange produces.

The mechanism

RFC 8693 defines a new grant type at the token endpoint. A client presents a token it holds and asks for a different one:

POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the user's access token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
audience=https://inventory.internal
scope=inventory:read

The authorization server validates the incoming token, checks that this client is permitted to perform this exchange, and returns a new token — narrower scope, different audience, fresh short expiry, and a record of who did the exchanging.

Rendering diagram…

Three properties follow, and each one closes a specific hole:

Audience restriction. Every token names exactly one intended recipient. Inventory rejects a token minted for billing. A stolen token is useful in exactly one place.

Progressive narrowing. Each exchange can only reduce scope, never expand it. The token reaching inventory carries inventory:read and nothing else. This is what bounds the damage from a compromised downstream service.

Preserved user identity with an audit trail. The sub claim still identifies the user. A new act claim records the chain of actors. Inventory knows both who and on whose behalf.

Delegation vs impersonation

The specification distinguishes two modes, and conflating them is the source of the interesting security bugs.

Delegation produces a token that says: this is Dana's request, being made by the orders service. Both identities are present and visible.

{
  "sub": "user:dana",
  "aud": "https://inventory.internal",
  "scope": "inventory:read",
  "act": { "sub": "svc:orders" },
  "exp": 1720000060
}

The act claim can nest, recording the full chain when a token is exchanged more than once:

{
  "sub": "user:dana",
  "act": { "sub": "svc:orders", "act": { "sub": "svc:gateway" } }
}

Impersonation produces a token that says: this is Dana's request. Full stop. No act claim, no record of who is really behind it. Downstream services cannot distinguish it from the user acting directly.

Impersonation has legitimate uses — most obviously a support engineer troubleshooting a customer's account. It is also strictly more dangerous, and the rule of thumb is simple: prefer delegation, always, unless the receiving system genuinely cannot understand an act claim. The moment you erase the actor, you have destroyed your audit trail, and "who actually did this" becomes unanswerable at exactly the moment you need the answer.

For the impersonation cases you do allow, the spec provides may_act — a claim on the subject's token declaring which parties are permitted to act as them:

{
  "sub": "user:dana",
  "may_act": { "sub": "svc:support-console" }
}

The authorization server refuses an impersonation exchange unless the requester matches. This turns "can this service impersonate users" from an implicit property of your network topology into an explicit, auditable policy.

If you build support impersonation, three things are non-negotiable regardless of the mechanism: the session is time-boxed, every action in it is logged as impersonated with both identities, and — for most products — the customer is notified or has consented. See audit logging for authentication for the record-keeping side.

What the authorization server must enforce

Token exchange concentrates a lot of authority in one endpoint. Six checks make it safe.

Authenticate the exchanging client. This is not a public-client flow. The service requesting an exchange must present real client credentials — ideally mTLS or a private-key JWT assertion rather than a shared secret.

Validate the subject token fully. Signature, expiry, issuer, and — importantly — that its audience includes the requesting service. A service should not be able to exchange a token that was never meant for it. Skipping this check reintroduces the confused deputy through the front door.

Enforce an exchange policy. An explicit matrix of which client may exchange for which audience. Without it, any service with credentials can mint a token for any other service, and you have rebuilt the "forward everything" model with extra steps.

Never widen scope. The issued scope must be a subset of the subject token's scope, intersected with what the policy allows and what the user is actually permitted to do. This is the same intersection rule described in scope design, and it is the invariant the whole model rests on.

Bound the chain. Nested act claims can grow without limit. Cap the depth, both to prevent unbounded token growth and because a five-deep actor chain usually means something has gone wrong architecturally.

Keep exchanged tokens short. Sixty seconds to a few minutes. An exchanged token exists to make one downstream call. There is no reason for it to outlive the request, and a short life is what makes revocation a non-issue — the trade-off examined in JWTs vs opaque tokens.

When you should not use this

Token exchange is real infrastructure, and it is frequently proposed for problems that have cheaper answers.

A monolith does not need it. If your "services" are modules in one process sharing one database, the authorization boundary is a function call. Exchanging tokens between them is ceremony with no security benefit.

Two or three services do not need it. With a small, fixed set of hops and a clear trust relationship, a validated user token with correct audience checks and per-service scope validation is adequate. Adopt token exchange when the number of services makes the "who can call whom on whose behalf" question genuinely hard to answer in your head.

A service mesh may already do part of it. Mutual TLS with SPIFFE identities gives you strong service identity for free. That is the easy half. The mesh does not carry the user identity or narrow scopes, so token exchange composes with it rather than being replaced by it — mTLS answers "which service," token exchange answers "on whose behalf, with what permissions."

Batch and async jobs need a different pattern. A job that runs six hours after a user's request cannot hold a sixty-second token. The workable design is to capture the authorization decision at request time — what the user was permitted to do, recorded durably — and have the worker act under its own service identity with that decision attached, rather than trying to keep a user token alive.

Adopting it incrementally

You do not need to convert everything at once, and the ordering matters.

Start by making audience checks real. Most systems already have a aud claim that nobody validates. Turning on strict audience validation is a day of work and immediately eliminates cross-service token replay — the largest single risk — before you introduce any new machinery.

Then introduce exchange at the most sensitive boundary — usually the one hop where a compromise would be worst, or the one that calls an external system. One boundary, fully instrumented, teaches you more about your policy needs than a design document.

Then expand outward along the calls you can actually enumerate. If you cannot enumerate them, that is the real finding, and it is worth knowing before you build anything.

Instrument throughout: log every exchange with requester, subject, audience, scope granted, and scope denied. The denials are the interesting data — they tell you where your policy is wrong, and they are the first signal of a service doing something it should not.

Key takeaways

  • Forwarding one token everywhere creates confused deputies; static service accounts destroy user identity; user IDs in headers are unauthenticated claims.
  • Token exchange issues a narrower, audience-restricted, short-lived token per hop while preserving who the user is.
  • Delegation keeps both identities in the act claim. Impersonation erases the actor — prefer delegation, and gate impersonation behind may_act.
  • The authorization server must authenticate the exchanging client, validate the subject token's audience, enforce an explicit exchange policy, and never widen scope.
  • Adopt it when service count makes trust relationships hard to reason about — not before. Turn on strict audience validation first; it is most of the benefit for a fraction of the work.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.