Token Exchange (RFC 8693)
A modern microservice graph almost always needs to call other services on behalf of the user. The frontend gets a token. Service A receives that token, then needs to call Service B. Should Service A reuse the user's token? Mint a new one? Combine the two? RFC 8693 defines the wire shape for that exchange.
The design fundamentally distinguishes two intents:
- Impersonation — Service A presents the user's token to Service B, as if it were the user. Service B sees
sub=alice. Audit trails downstream see Alice acting alone. - Delegation — Service A asks the OP for a new token whose
subis still Alice but whoseactclaim carries{sub: service-a}, recording that Alice's authority is being exercised through Service A. Service B seessub=alice, act={sub: service-a}and can apply policy that depends on the chain ("Service A may withdraw Alice's funds, but only if the request originated from Alice").
Modern threat models prefer delegation — the audit chain is intact, revocation can target the intermediary, and least-privilege tightens around the actor rather than spreading from the subject.
Specs referenced on this page
Vocabulary refresher
- subject_token — the token whose holder's identity should populate
subon the new token. Usually the user's access token forwarded from upstream. - actor_token — the token identifying the caller (the service performing the exchange). When present, the new token gains an
actclaim wrapping the actor'ssub/client_id. actclaim (RFC 8693 §4.1) — a nested object recording who is acting on behalf ofsub. It can chain (act.act.act…) so a four-hop call records all four intermediaries.cnfrebinding — the issued token'scnf(RFC 7800 confirmation) is set to the calling actor's verified DPoP / mTLS proof, not the subject's. The token is sender-bound to the service performing the exchange.
Impersonation vs Delegation, side by side
actor_token. It costs one parameter and it is the difference between an audit log that says "alice did this" and one that says "alice did this, through service A".The difference shows up in audit trails (Service B knows who actually originated the call) and in policy (Service B can require an actor when the operation is sensitive).
What the OP enforces
The library's RFC 8693 handler is opinionated about a few things the spec leaves to deployment policy:
actchain is built on the OP side, not handed in by the caller. Wheneveractor_tokendiffers fromsubject_token, the OP populatesactfrom the actor's verified credentials. A caller cannot fabricate anactclaim.- Audience is normalised and allow-listed. RFC 8707
audience/resourceparameters are normalised before policy evaluation; when both are omitted, the subject token's audience supplies the default. The client must be authorised for every resulting audience. - Scope is intersected, not unioned. The issued token's scope is the intersection of (requested scope,
subject_token's scope, calling client's allow-list). RFC 8693 §3.1 permits scope reduction; the OP forbids inflation. - TTL is capped by the minimum of (handler request,
subject_tokenremaining lifetime, OP global ceiling). A long-lived token cannot be laundered into a longer one. cnfis rebound to the calling actor. If Service A presents a DPoP proof on the exchange request, the issued token'scnf.jktmatches Service A's DPoP key — not the user's, not the subject_token's. Service B verifies the DPoP proof against the new token'scnf.
These rules are enforced before the embedder-supplied TokenExchangePolicy is consulted. The policy can narrow further (deny audiences, deny actor combos) but cannot widen — the OP-computed defaults are a floor.
Resource and audience normalisation
The OP applies one comparison form to URI-shaped resource and audience values across token exchange and the other resource-indicator surfaces. A URI value must be absolute and include both a scheme and a host. The OP lowercases the scheme and host, removes the default port (80 for http and 443 for https), and removes trailing slashes from the path; path case is preserved. Query text and non-default ports are preserved verbatim. Fragments and userinfo are rejected, so values such as https://[email protected]/api or https://api.example.com/api#v1 never match an allow-list entry.
For example, https://API.Example.com:443/v1/?tenant=acme compares as https://api.example.com/v1?tenant=acme. A request that omits both audience and resource inherits the subject token's audience after the same normalisation. Logical audience labels that are not URI-shaped, including URNs, remain byte-exact; the OP does not lowercase or otherwise rewrite them.
When you actually need it
You probably do not need token exchange when:
- Both services are owned by the same team and trust each other implicitly. Use the user's token directly with a multi-
audaudience set. - The downstream service does not need to know about the upstream actor. Pass the user's token through (with the right
aud).
You probably do need it when:
- The downstream service applies policy that depends on who is in the call chain — "the wire-transfer service trusts the mobile app, but not the SMS bot, even when both invoke it as Alice".
- A third-party service is in the chain and your audit obligation requires recording the cross-org actor.
- Sender-binding (DPoP / mTLS) needs to follow the calling service, not the user.
See it run
examples/33-token-exchange-delegation ships a frontend → service-a → service-b chain. The frontend obtains a user token, service-a exchanges it for a delegated token (with act={sub: service-a}), service-b's RS-side verifier walks act.sub and accepts only delegated tokens.
(cd examples/33-token-exchange-delegation && GOWORK=off go run -tags example .)The example is split into role-tagged files (op.go for the OP wiring + TokenExchangePolicy, service_a.go for the intermediary, service_b.go for the resource server, probe.go for self-verification).
Read next
- Use case: token-exchange wiring —
op.RegisterTokenExchange, theTokenExchangePolicycontract, configuring audiences, refresh-issuance opt-in viaop.PtrBool(true). - Custom Grant wiring — token exchange is the in-tree example of a "custom grant_type" the OP routes; embedders writing their own URN follow the same shape via
op.WithCustomGrant.