Token exchange is one of those topics that's easier to see than
to read about. So instead of a wall of explanation, this page hands you
the controls. Step through it once and the shape of the protocol — the
audience pinning, the short TTLs, the nested act claim
that carries the chain of custody — should be obvious.
The cast is deliberately small and generic. One user, two agents, one
tool, and the two pieces of infrastructure that make the whole thing
work: an OIDC IdP (the demo uses a generic
oidc-idp — any OIDC-compliant provider works) and the
agentgateway Security Token Service (the per-hop
minter).
The cast
Any OIDC-compliant identity provider. The only thing that turns a username/password (or client credentials) into a first-class JWT. Mints the initial subject token.
The user kicking off the workflow. Authenticates against the IdP. Her identity becomes the root of the act chain.
First agent in the chain. Receives Alice's call and decides it needs to invoke the planner. Must obtain a planner-scoped token before it can.
RFC 8693 Security Token Service, built into agentgateway. Validates a subject token, checks the calling client's permissions, mints a fresh audience-pinned token with the prior subject nested in act.
Second agent. Receives orchestrator's call, decides to invoke an MCP tool. Same routine: hand the inbound token to the STS, get back a tool-scoped one.
The sensitive resource at the end of the chain. The gateway in front of it can read the nested act claim and assert the chain matches policy before letting the request through.
The walkthrough
What each field in the JWT means
| Field | Name | What it means |
|---|---|---|
| iss | Issuer | Who signed the token. The first one comes from the OIDC IdP. Every subsequent token is signed by the agentgateway STS. Receivers fetch JWKS at this URL to verify the signature. |
| sub | Subject | Who the token is about — i.e. who's making the current call. Changes at every hop: alice → orchestrator → planner. The prior subject is preserved in act. |
| aud | Audience | Who the token is for. Pinned to exactly the next callee. A leaked hop-1 token is useless against tool-mcp because the audience won't match. |
| exp | Expiry | Short — 10 minutes in this demo. Each minted token gets its own fresh TTL. Combined with audience pinning, makes a stolen token a small-blast-radius problem. |
| act | Actor / chain | Defined in RFC 8693 §4.1. Each token-exchange wraps the previous sub here. After two hops it's nested two levels deep — the entire call chain is signed into one token. |
| sig | Signature | JWS over the base64-encoded header+payload, signed by the issuer's private key. A tampered act claim breaks the signature, so you can't forge a chain. |
Why it matters
Three properties fall out of this design that you don't get with a plain bearer-token-forwarded-everywhere approach:
- Short-lived — every hop mints a fresh token with a 10-minute TTL. A stolen token rots quickly.
- Single-purpose — every token is pinned to one audience. tool-mcp won't accept a planner-bound token. The orchestrator can't replay Alice's subject token anywhere except where the IdP said it could.
- Signed lineage — the
actchain encodes who walked the request through. A sensitive resource can demand "the immediate caller is the planner, and the chain shows orchestrator and then alice at the root" — and the gateway checks it as CEL on the inbound token. No out-of-band trust needed.
Negative tests
The walkthrough above is the happy path. The protocol is only as good as the rejections, so the cases below are the ones worth running explicitly when you stand up an STS-fronted setup. They all come down to the same idea: the chain is signed, audience-pinned and short-lived, so any attempt to skip a hop, widen scope or replay a token should be visibly rejected by either the STS or the receiving gateway. Curl examples below use the same hostnames as the walkthrough.
Prereq — mint the subject tokens. Cases 1–3 and 5
need a valid IdP-style subject token plus a tampered variant. Run
the shared
Mint your own test JWTs
helper once and source tokens.env — that gives you
$VALID_JWT, $TAMPERED_JWT,
$EXPIRED_JWT and $WRONG_AUD_JWT.
Configure the STS so the orchestrator client's allowed subject
audience is api.example.com (what the helper signs)
and trusted issuer is https://test-idp.example.com.
Cases 4, 6 and 7 reference tokens the STS itself produces
($HOP1_TOKEN, $EXPIRED_HOP2_TOKEN,
$HOP2_FROM_PREVIOUS_KEY) — those come from the
walkthrough's exchange calls, not from the mint script.
Case 1 — STS rejects an invalid subject_token
Orchestrator tries to exchange a token whose signature does not
verify (use $TAMPERED_JWT), whose exp is
in the past ($EXPIRED_JWT), or whose aud
is not what the orchestrator client is configured to accept
($WRONG_AUD_JWT). The STS refuses to mint anything.
curl -i -X POST https://agw-sts.example.com/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=$TAMPERED_JWT \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=planner \
-d client_id=orchestrator \
-d client_secret=$ORCH_SECRET
# HTTP/1.1 400 Bad Request
# {"error":"invalid_grant","error_description":"subject_token verification failed"}
Case 2 — STS refuses to widen the audience
Orchestrator presents a valid subject token ($VALID_JWT)
and asks for a token bound to a service it is not registered to
call (aud=billing). Even though the subject token is
valid, the STS rejects because the calling client is not permitted
to obtain a token for that audience.
curl -i -X POST https://agw-sts.example.com/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=$VALID_JWT \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=billing \
-d client_id=orchestrator \
-d client_secret=$ORCH_SECRET
# HTTP/1.1 403 Forbidden
# {"error":"invalid_target","error_description":"client not permitted for requested audience"}
Case 3 — STS will not widen scope
Subject token carries scope=invoke.orchestrator (the
helper sets this by default on $VALID_JWT).
Orchestrator asks the STS for
scope="invoke.planner admin.planner". The STS may
downscope (return narrower or equivalent scope), but it must not
hand out a scope the subject was not authorised for.
curl -s -X POST https://agw-sts.example.com/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token=$VALID_JWT \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=planner \
-d scope="invoke.planner admin.planner" \
-d client_id=orchestrator \
-d client_secret=$ORCH_SECRET \
| jq -r .scope
# "invoke.planner"
Case 4 — tool-mcp rejects a token without the expected act chain
Skip the second exchange. Try to call tool-mcp directly with the
hop-1 token (the one issued to orchestrator with
aud=planner). The gateway in front of tool-mcp runs
the CEL listed under step 7 of the walkthrough, sees that
aud != tool-mcp and that act.act.sub
is missing, and rejects.
curl -i -H "Authorization: Bearer $HOP1_TOKEN" \
https://tool-mcp.example.com/mcp
# HTTP/1.1 403 Forbidden
# response body: Audiences in Jwt are not allowed
Case 5 — tool-mcp rejects a blind pass-through of Alice's subject token
The strongest negative case in the whole picture. An attacker
who somehow obtains Alice's original subject token
($VALID_JWT here) tries to use it directly against
tool-mcp. The token is signed by the IdP (not the STS), its
aud is api.example.com not
tool-mcp, and it carries no act claim.
The receiving gateway rejects on every count.
curl -i -H "Authorization: Bearer $VALID_JWT" \
https://tool-mcp.example.com/mcp
# HTTP/1.1 401 Unauthorized
# response body: Jwt issuer is not configured (STS-only trust)
Keep this case in your evidence pack: it proves the exchange isn't decoration. Without going through the STS, there is no token that tool-mcp will accept.
Case 6 — receiving gateway rejects on expired hop-2 token
Hop-2 tokens carry a 10-minute TTL. Hold one for 11 minutes and
replay it. The gateway in front of tool-mcp rejects on the
exp check, even though every other claim still
matches.
curl -i -H "Authorization: Bearer $EXPIRED_HOP2_TOKEN" \
https://tool-mcp.example.com/mcp
# HTTP/1.1 401 Unauthorized
# response body: Jwt is expired
Case 7 — replay-after-rotation
Rotate the STS signing key. Tokens minted under the previous key remain syntactically valid but their signature no longer verifies against the new JWKS. Replay one and the receiving gateway rejects.
curl -i -H "Authorization: Bearer $HOP2_FROM_PREVIOUS_KEY" \
https://tool-mcp.example.com/mcp
# HTTP/1.1 401 Unauthorized
# response body: Jwt verification fails
This is the case to run when you want to demonstrate the blast-radius story: a leaked private key, once rotated out, can no longer mint anything that the system will accept on the next fetch of the JWKS.
What to log. Every rejection above should produce
an STS audit record (or, for cases 4–7, a receiving-gateway
access log) containing the calling client, requested audience
and scope, the error code, and the jti of the
token. The token contents themselves must not appear in the log.
Together those entries are the auditor's view of why a request
was rejected without ever leaking a credential.
Where to go from here
Sister page on this site: JWT, OIDC and on-behalf-of covers where the RFC 8693 surface attaches to Istio Ambient and where it lives in Solo Enterprise vs OSS, plus the OIDC login flow that fronts the whole thing. claimsToHeaders covers what happens to verified JWT claims once a backend wants to read them as request headers.
Upstream references:
RFC 8693 (Token Exchange),
RFC 8693 §4.1 — the act claim,
Keycloak's token-exchange docs (one OIDC IdP example).
Solo:
agentgateway token exchange,
on-behalf-of for MCP.