MastertheMesh
agentgateway · Reference
Interactive

JWT token exchange — OIDC IdP + agentgateway STS, hop by hop

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Click Next (or hit Play) and watch a JWT travel from an OIDC IdP login, through two agent hops, into an MCP tool. The token is reminted at every hop by an RFC 8693 Security Token Service — the act claim nests deeper each time, so the receiver can read the whole call chain off one signed token.

OIDC IdP agentgateway STS RFC 8693 A2A act claim

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

IdP
oidc-idp

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.

Human
alice

The user kicking off the workflow. Authenticates against the IdP. Her identity becomes the root of the act chain.

Agent
orchestrator

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.

STS
agentgateway STS

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.

Agent
planner

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.

MCP
tool-mcp

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

STEP 0 / 7 Press Next to begin
oidc-idp IdP alice user orchestrator agent agentgateway STS RFC 8693 planner agent tool-mcp MCP server ① login subject token ② invoke (subject JWT) ③ exchange (subject_token, aud=planner) hop-1 token (act = alice) ④ invoke (hop-1 JWT) ⑤ exchange (subject_token=hop-1, aud=tool-mcp) hop-2 token (act nested) ⑥ invoke (hop-2 JWT) ⑦ chain validated · 200 OK

What's on the wire

No request in flight yet.

The JWT — what's inside the token in flight

No token has been minted yet.

This walkthrough takes you from an OIDC IdP login to an MCP tool call in seven clicks. Each step shows the HTTP request on the wire (left) and the JWT it carries or produces (right). The act claim is the one to watch — it's how chain-of-custody gets encoded.

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:

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.