Chapter 24 — Token exchange and delegation
In one sentence
When service A calls service B on behalf of a user, B must receive a token that is for B, carries the user's identity and the fact that A is acting, and cannot be replayed elsewhere — which is what OAuth 2.0 Token Exchange (RFC 8693) and audience-restricted tokens provide.
Where we are
G1 Ch 46 got a user token to your API. Now that API calls three more services, each of which calls more. Forwarding the original user token everywhere is a replay disaster; minting god tokens is worse. This chapter is the propagation model.
How it works
The problem with naive approaches:
FORWARD THE USER'S TOKEN UNCHANGED to every downstream service
- the token's `aud` is the first API, not the downstream -> either
downstreams don't check `aud` (bad) or it fails.
- any compromised downstream can replay the user's token to ANY
service that accepts it -> lateral movement with the user's full
rights.
- the token may carry scopes far beyond what this call needs.
USE A SHARED SERVICE ACCOUNT / API KEY between services
- loses the user's identity (B can't do per-user authz or audit).
- one leaked key = impersonate the whole service.
PUT THE USER ID IN A HEADER (X-User-Id: 42) and trust it
- any service (or attacker with network reach) sets it. No.
Token exchange (RFC 8693): a service presents its own identity plus the
incoming token to an STS (your IdP, or a local exchange service), and gets back a
new token minted for the specific downstream, carrying the user as sub and
the caller as an actor.
POST /token (from `orders-api`, authenticating with its SPIFFE
JWT-SVID or client credential)
grant_type = urn:ietf:params:oauth:grant-type:token-exchange
subject_token = <the user's access token that orders-api received>
subject_token_type= urn:ietf:params:oauth:token-type:access_token
actor_token = <orders-api's own identity token / SVID>
audience = "spiffe://securesh.op/ns/core/sa/payments-api"
scope = "charge:create" # only what THIS call needs
-> a new JWT:
iss = your STS
sub = user:42 # the end user, preserved
aud = payments-api # ONLY payments-api accepts it
scope = "charge:create" # down-scoped
act = { sub: "spiffe://.../core/sa/orders-api",
act: { sub: "spiffe://.../edge/sa/bff" } } # the delegation
# chain
exp = now + 60s # very short
The rules every service enforces:
[ ] Validate the signature, `iss`, `exp`, `nbf` (G1 Ch 46).
[ ] Validate `aud` == THIS service's identity. A token for payments-api
is rejected by catalog-api. This single check kills cross-service
replay.
[ ] Read `sub` for the end user and `act` for the calling-service
chain; use both for authz (Ch 25) and audit.
[ ] Keep exchanged tokens SHORT-LIVED (seconds) -- they only need to
survive one hop.
[ ] Optionally enforce `may_act` -- the subject token can carry
"who is allowed to act on my behalf"; the STS checks the actor is
in it.
Deployment patterns:
PHANTOM TOKEN the public client holds an OPAQUE reference token. The
gateway calls the IdP's introspection endpoint, gets the real signed
JWT, and forwards THAT internally (never to the public internet).
Downstream services do token exchange from there. Keeps real tokens
off the client.
SPLIT TOKEN the gateway keeps the signature; the client holds
header.payload; the gateway recombines. Similar goal.
MESH + TOKEN EXCHANGE the mesh gives you service identity (mTLS,
Ch 23); an in-cluster STS (Keycloak, a small service, or your IdP)
does the exchange; sidecars can be configured to call it.
OBO (on-behalf-of) Microsoft's name for essentially the same
middle-tier exchange flow.
mTLS vs token — you use both: the mTLS/SPIFFE layer authenticates the service connection and does coarse "may A talk to B". The exchanged token carries the user context and the delegation chain for per-user, per-request authz and audit.
Worked example
A compromised catalog-api can't touch payments.
Flow: user -> bff -> orders-api -> (needs a price check) -> catalog-api
\-> (needs to charge) -> payments-api
bff receives the user's access token (aud=bff). It does token exchange
-> token T1 (sub=user:42, aud=orders-api, scope="order:create",
act=bff, exp=60s). Calls orders-api with T1 over mTLS.
orders-api validates T1 (aud==orders-api OK). It needs two downstream
calls, so two exchanges:
-> T2: aud=catalog-api, scope="price:read", act={orders-api,{bff}}
-> T3: aud=payments-api, scope="charge:create", act={orders-api,{bff}}
ATTACK: catalog-api is compromised (say an SSRF -> RCE). The attacker
has T2 in memory.
- Replay T2 to payments-api? aud=catalog-api != payments-api -> 401.
- Replay T2 to orders-api or the DB service? aud mismatch -> 401.
- Mint a new token? No STS credential for payments' audience, no
signing key.
- Wait for a fresh token? T2 lives 60s and only grants price:read.
Blast radius of a catalog-api compromise toward the money path: nil.
The attacker can misbehave *as catalog-api within its own scope* and
nothing more. Every hop is logged with the full `act` chain -> the IR
team sees exactly where the chain broke (Ch 55).
Practice (60 min)
- Stand up Keycloak (or
ory/hydra+ a small exchange shim) configured for token exchange. Registerbff,orders-api,catalog-api,payments-apias clients/audiences. - Write
orders-apiso it: validates the incoming token'saud, then calls the STS to exchange foraud=catalog-api(scopeprice:read) andaud=payments-api(scopecharge:create), eachexp~60s. - Prove: a token minted for
catalog-apiis rejected bypayments-api(audcheck). Remove theaudcheck frompayments-apiand show the replay now works — then put it back. - Add the
actchain; log it at every hop; reconstruct the delegation chain from logs for one request. - Implement the phantom token pattern at a gateway (opaque token in, introspect, forward JWT).
- Combine with Ch 23: require mTLS/SPIFFE for the connection and a valid audience-scoped token for the request.
Build it in Go (45 min) — signed tokens, verified properly, and token exchange
This lab mints and verifies compact JWTs with Ed25519 using only the standard library, so every check a JWT library should make is visible. It also implements a token-exchange endpoint: the orders service trades the user's token for a narrower, shorter-lived token for the payments API, recording itself as the actor.
// tokens: signed tokens done carefully, and token exchange (RFC 8693).
//
// A JWT is three base64url parts: header.claims.signature. This lab builds
// and verifies them with Ed25519 using only the standard library, then shows
// the checks that real-world token bugs skip: a PINNED algorithm, issuer,
// audience, expiry -- and a token-exchange service that turns a user's token
// for the "orders" API into a narrower, shorter-lived token for "payments".
//
// go run ./tokens
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
)
type claims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
Scope []string `json:"scope"`
Act *actor `json:"act,omitempty"` // who is acting on the subject's behalf
}
type actor struct {
Sub string `json:"sub"`
}
var b64 = base64.RawURLEncoding
type issuer struct {
name string
kid string
priv ed25519.PrivateKey
}
func (is issuer) mint(c claims) string {
c.Iss = is.name
c.Iat = time.Now().Unix()
h, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT", "kid": is.kid})
p, _ := json.Marshal(c)
signing := b64.EncodeToString(h) + "." + b64.EncodeToString(p)
return signing + "." + b64.EncodeToString(ed25519.Sign(is.priv, []byte(signing)))
}
// verifier holds the trusted keys by kid and the checks for ONE audience.
type verifier struct {
keys map[string]ed25519.PublicKey // from the issuer's JWKS, fetched and cached
issuer string
audience string
}
func (v verifier) verify(tok string) (*claims, error) {
parts := strings.Split(tok, ".")
if len(parts) != 3 {
return nil, errors.New("malformed token")
}
var hdr struct{ Alg, Kid string }
hb, err := b64.DecodeString(parts[0])
if err != nil || json.Unmarshal(hb, &hdr) != nil {
return nil, errors.New("bad header")
}
// 1. PIN the algorithm. Never let the token choose it ("alg":"none",
// or RS256->HS256 confusion are the classic forgeries).
if hdr.Alg != "EdDSA" {
return nil, fmt.Errorf("algorithm %q not allowed", hdr.Alg)
}
key, ok := v.keys[hdr.Kid]
if !ok {
return nil, fmt.Errorf("unknown key id %q", hdr.Kid)
}
sig, err := b64.DecodeString(parts[2])
if err != nil || !ed25519.Verify(key, []byte(parts[0]+"."+parts[1]), sig) {
return nil, errors.New("bad signature")
}
// 2. Only now trust the claims.
var c claims
pb, _ := b64.DecodeString(parts[1])
if err := json.Unmarshal(pb, &c); err != nil {
return nil, errors.New("bad claims")
}
now := time.Now().Unix()
switch {
case c.Iss != v.issuer:
return nil, fmt.Errorf("wrong issuer %q", c.Iss)
case c.Aud != v.audience: // a token for "orders" must not work at "payments"
return nil, fmt.Errorf("token is for %q, not %q", c.Aud, v.audience)
case now >= c.Exp+30: // 30 s leeway for clock skew
return nil, errors.New("expired")
}
return &c, nil
}
// exchange is a token-exchange endpoint: it accepts a valid token for the
// caller's own API and issues a DOWNSCOPED token for one downstream API.
func exchange(is issuer, subjectTok string, actorID, target string, wantScope []string) (string, error) {
in, err := verifier{keys: map[string]ed25519.PublicKey{is.kid: is.priv.Public().(ed25519.PublicKey)},
issuer: is.name, audience: "orders"}.verify(subjectTok)
if err != nil {
return "", fmt.Errorf("subject token rejected: %w", err)
}
var scope []string
for _, s := range wantScope { // never MORE than the user had
if slices.Contains(in.Scope, s) {
scope = append(scope, s)
}
}
exp := min(in.Exp, time.Now().Add(2*time.Minute).Unix()) // never LONGER than the original
return is.mint(claims{Sub: in.Sub, Aud: target, Exp: exp, Scope: scope, Act: &actor{actorID}}), nil
}
func main() {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
idp := issuer{"https://id.shop.example", "key-2026-10", priv}
keys := map[string]ed25519.PublicKey{idp.kid: pub}
ordersAPI := verifier{keys, idp.name, "orders"}
paymentsAPI := verifier{keys, idp.name, "payments"}
userTok := idp.mint(claims{Sub: "user-42", Aud: "orders", Exp: time.Now().Add(15 * time.Minute).Unix(),
Scope: []string{"orders:write", "payments:charge", "profile:read"}})
try := func(label string, v verifier, tok string) {
c, err := v.verify(tok)
if err != nil {
fmt.Printf("%-46s REJECT %v\n", label, err)
return
}
act := ""
if c.Act != nil {
act = " act=" + c.Act.Sub
}
fmt.Printf("%-46s ACCEPT sub=%s aud=%s scope=%v ttl=%ds%s\n", label, c.Sub, c.Aud, c.Scope, c.Exp-time.Now().Unix(), act)
}
try("user token at orders", ordersAPI, userTok)
try("same token replayed at payments", paymentsAPI, userTok)
// Forgery 1: "alg":"none" with no signature.
h := b64.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
p := strings.Split(userTok, ".")[1]
try(`forged "alg":"none"`, ordersAPI, h+"."+p+".")
// Forgery 2: edit the claims (escalate to admin), keep the old signature.
var c claims
pb, _ := b64.DecodeString(p)
json.Unmarshal(pb, &c)
c.Scope = append(c.Scope, "admin")
pj, _ := json.Marshal(c)
parts := strings.Split(userTok, ".")
try("claims edited, old signature", ordersAPI, parts[0]+"."+b64.EncodeToString(pj)+"."+parts[2])
try("expired token", ordersAPI, idp.mint(claims{Sub: "user-42", Aud: "orders", Exp: time.Now().Add(-time.Hour).Unix()}))
// Token exchange: the orders service calls payments ON BEHALF of user-42.
down, err := exchange(idp, userTok, "spiffe://shop.example/ns/prod/sa/orders", "payments",
[]string{"payments:charge", "payments:refund"})
if err != nil {
fmt.Println(err)
return
}
try("exchanged token at payments", paymentsAPI, down)
try("exchanged token replayed at orders", ordersAPI, down)
}
$ go run ./tokens
user token at orders ACCEPT sub=user-42 aud=orders scope=[orders:write payments:charge profile:read] ttl=900s
same token replayed at payments REJECT token is for "orders", not "payments"
forged "alg":"none" REJECT algorithm "none" not allowed
claims edited, old signature REJECT bad signature
expired token REJECT expired
exchanged token at payments ACCEPT sub=user-42 aud=payments scope=[payments:charge] ttl=120s act=spiffe://shop.example/ns/prod/sa/orders
exchanged token replayed at orders REJECT token is for "payments", not "orders"
What to notice:
- The audience check stops lateral replay. Without it, any service that receives a user's token can use it against every other service, which turns one compromised service into all of them (Chapter 3's lateral movement).
- The algorithm is pinned by the verifier, never read from the token. The
"alg":"none"and RS256→HS256 confusion forgeries both depend on the verifier trusting the header. - The exchange narrowed everything. The caller asked for
payments:chargeandpayments:refund, got onlypayments:charge(the user never hadrefund), and the lifetime fell from 15 minutes to 2. Theactclaim keeps the delegation visible in payments' audit log. - Verify the signature before parsing claims you act on. The code decodes the header first (only to choose the key), then verifies, and only then trusts the claims.
Exercises:
- Add
kidrotation: publish two public keys, mint with the new one, and keep accepting the old one until its tokens expire. - Add OIDC ID-token checks (Chapter 39):
noncemust match the login request, andazpmust be your client ID. - Add a
jticlaim and a replay cache for one-time tokens (password reset, email verification).
Common mistakes
- Forwarding the user's original token to every service. Replayable everywhere that token is accepted. Exchange it, scoped per hop.
- Not validating
aud. This is the check that prevents cross-service replay. Every service, every request. - Long-lived exchanged tokens. They only need to survive one hop — seconds, not hours.
- Trusting
X-User-Id/X-Tenant-Idheaders between services. Any process with network reach sets them. Derive identity from the validated token. - A shared service-account token for all inter-service calls. Loses user context; one leak impersonates the fleet. Use per-service identity + exchange.
- Dropping the actor chain.
actis what lets you audit and authorize "bff via orders-api acting for user 42".
Check yourself
- Give three problems with forwarding the user's original access token to every downstream service.
- In RFC 8693 token exchange, what do
subject_token,actor_token,audience, and the resultingactclaim represent? - Which single token check prevents cross-service replay, and where is it enforced?
- What is the phantom-token pattern and what does it protect?
- Why do you still need mTLS/SPIFFE if every request carries an audience-scoped token?
- Why keep exchanged tokens extremely short-lived?
Further reading
- RFC 8693 — OAuth 2.0 Token Exchange. Read the
act/may_actsections. - Patterns: Curity's "Token Handler" / "Phantom Token" / "Split Token"
pattern write-ups (
curity.io/resources/learn); Microsoft "on-behalf-of flow". - Guidance: "Microservices Security in Action" (Siriwardena & Dias) — the token-propagation chapters; OWASP "Microservices Security Cheat Sheet".
- Implementations: Keycloak token exchange, Ory Hydra,
oauth2-proxy, Istio's request-authentication +RequestAuthentication/AuthorizationPolicywith JWT.