Frontend Labs
⋯

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)

  1. Stand up Keycloak (or ory/hydra + a small exchange shim) configured for token exchange. Register bff, orders-api, catalog-api, payments-api as clients/audiences.
  2. Write orders-api so it: validates the incoming token's aud, then calls the STS to exchange for aud=catalog-api (scope price:read) and aud=payments-api (scope charge:create), each exp ~60s.
  3. Prove: a token minted for catalog-api is rejected by payments-api (aud check). Remove the aud check from payments-api and show the replay now works — then put it back.
  4. Add the act chain; log it at every hop; reconstruct the delegation chain from logs for one request.
  5. Implement the phantom token pattern at a gateway (opaque token in, introspect, forward JWT).
  6. 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:charge and payments:refund, got only payments:charge (the user never had refund), and the lifetime fell from 15 minutes to 2. The act claim 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:

  1. Add kid rotation: publish two public keys, mint with the new one, and keep accepting the old one until its tokens expire.
  2. Add OIDC ID-token checks (Chapter 39): nonce must match the login request, and azp must be your client ID.
  3. Add a jti claim 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-Id headers 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. act is what lets you audit and authorize "bff via orders-api acting for user 42".

Check yourself

  1. Give three problems with forwarding the user's original access token to every downstream service.
  2. In RFC 8693 token exchange, what do subject_token, actor_token, audience, and the resulting act claim represent?
  3. Which single token check prevents cross-service replay, and where is it enforced?
  4. What is the phantom-token pattern and what does it protect?
  5. Why do you still need mTLS/SPIFFE if every request carries an audience-scoped token?
  6. Why keep exchanged tokens extremely short-lived?

Further reading

  • RFC 8693 — OAuth 2.0 Token Exchange. Read the act/may_act sections.
  • 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/AuthorizationPolicy with JWT.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36