In agentic AI systems, one of the most challenging security problems is the identity boundary: how do you safely delegate user identity to an agent, and then from that agent to downstream services that each require different credentials?
The naive approach: passing a user's broad-scoped IdP token through every hop, is a security disaster. That token likely grants access to dozens of services, contains sensitive claims, and was never meant to leave the user's browser. If an agent sees that token, it can impersonate the user anywhere. As Christian Posta wrote in the AgentGateway blog:
"Shielding AI agents from sensitive MCP / API credentials (secrets, API keys, tokens, etc) is the prevailing best practice."
Token exchange solves this by replacing the user's original token with a fresh, scoped, short-lived credential before it reaches the agent. The user's identity is preserved (sub
claim), but the token's audience, scope, and lifetime are reshaped for the specific downstream service.
In this tutorial, I walk through how to implement token exchange in AgentGateway, the Rust-based agentic AI proxy from the AI Agent Infrastructure Foundation (AAIF), using RFC 8693 (OAuth 2.0 Token Exchange) and the built-in backendAuth.oauthTokenExchange
policy. By the end, you will have a working end-to-end setup where:
Agents act on behalf of users. When a user invokes an agent that calls multiple downstream services (MCP servers, APIs, databases), each service needs to know:
sub
claim)Passing the user's original IdP token to every service fails all four:
act
claim)Without gateway-managed exchange, as Posta notes, "you get N credential stores and zero consistent audit: exactly the leak surface" you want to avoid.
RFC 8693 defines a standard OAuth 2.0 grant type (urn:ietf:params:oauth:grant-type:token-exchange
) that lets a client (the gateway) exchange one security token (the user's JWT) for another (a downstream-scoped JWT).
The gateway becomes the security boundary:
User JWT (broad, long-lived)
↓
AgentGateway exchanges at IdP
↓
Downstream JWT (scoped, short-lived, audience-specific)
↓
Upstream service
The upstream service never sees the user's original token. It only sees a token minted specifically for it, with the correct audience and minimal scopes.
As of the July 12, 2026 release, AgentGateway supports three exchange mechanisms under backendAuth.oauthTokenExchange
:
| Grant | Spec | Subject Parameter | Typical IdP |
|---|---|---|---|
| Token exchange (default) | |||
| RFC 8693 | subject_token |
||
| Keycloak, Okta, ZITADEL, Auth0 | |||
| JWT bearer | |||
| RFC 7523 | assertion |
||
| Keycloak JWT Authorization Grant | |||
| Entra OBO | |||
| Microsoft's jwt-bearer variant | assertion |
||
| Microsoft Entra ID |
This tutorial focuses on the RFC 8693 token exchange grant with Keycloak as the IdP. I cover the JWT bearer and Entra OBO approaches at the end.
Before starting, ensure you have:
rustc >= 1.91.1
(check with rustc --version
)Here is what I built:
requester-client
)/exchange
with Authorization: Bearer <user-jwt>
Authorization
headeraud=target-client
Authorization: Bearer <exchanged-token>
Clone the AgentGateway repository:
git clone https://github.com/agentgateway/agentgateway.git
cd agentgateway
Build AgentGateway from source:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
cargo build --release
Actual output from my build on RHEL 9:
$ rustc --version
rustc 1.97.0 (2d8144b78 2026-07-07)
$ cargo build --release
Compiling agentgateway v0.0.0 (/home/margonza/agentgateway/crates/agentgateway)
Compiling agentgateway-app v0.0.0 (/home/margonza/agentgateway/crates/agentgateway-app)
Finished `release` profile [optimized] target(s) in 23m 19s
The first build takes ~20 minutes as it compiles all dependencies. Subsequent builds are incremental and much faster.
AgentGateway ships with a complete working example in examples/traffic-token-exchange/oauth-rfc8693/
. This is the one I used.
If you have Docker:
docker compose -f examples/traffic-token-exchange/oauth-rfc8693/docker-compose.yaml up -d
If you have Podman (as I did on RHEL 9, without compose), start the containers manually:
podman network create agw-token-exchange
podman run -d \
--name backend-oauth-keycloak \
--network agw-token-exchange \
-e KC_HOSTNAME=localhost \
-e KC_HOSTNAME_PORT=7080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
-e KC_HEALTH_ENABLED=true \
-e KC_LOG_LEVEL=info \
-v ./examples/traffic-token-exchange/oauth-rfc8693/backend-oauth-realm.json:/opt/keycloak/data/import/backend-oauth-realm.json:ro,Z \
-p 7080:7080 \
-p 7443:7443 \
quay.io/keycloak/keycloak:26.3 \
start-dev --import-realm --http-port 7080 --https-port 7443
podman run -d \
--name backend-oauth-upstream \
--network agw-token-exchange \
-p 18080:8080 \
registry.istio.io/testing/app
What this sets up:
http://localhost:7080
, realm backend-oauth
, pre-seeded with:
initial-client
/ initial-secret
: user authenticates here firstrequester-client
/ requester-secret
: AgentGateway uses this to request exchangestarget-client
/ target-secret
: audience for exchanged tokenstestuser
/ testpass
http://localhost:18080
: reflects request headers so you can see the exchanged tokenWait for Keycloak to be ready (it takes about 15-20 seconds):
until curl -sf http://localhost:7080/realms/backend-oauth/.well-known/openid-configuration > /dev/null; do
echo "Waiting for Keycloak..."
sleep 3
done
echo "Keycloak is ready!"
Actual output:
Waiting for Keycloak...
Waiting for Keycloak...
Waiting for Keycloak...
Keycloak is ready!
Verify the realm is imported:
curl -s http://localhost:7080/realms/backend-oauth/.well-known/openid-configuration | jq -r '.issuer, .token_endpoint'
Actual output:
http://localhost:7080/realms/backend-oauth
http://localhost:7080/realms/backend-oauth/protocol/openid-connect/token
Keycloak logs will show the realm import succeeded:
2026-07-13T10:00:09.577 INFO [org.keycloak.exportimport.dir.DirImportProvider] Importing from directory /opt/keycloak/data/import
2026-07-13T10:00:09.582 INFO [org.keycloak.services] KC-SERVICES0050: Initializing master realm
2026-07-13T10:00:11.161 INFO [org.keycloak.services] KC-SERVICES0030: Full model import requested. Strategy: IGNORE_EXISTING
2026-07-13T10:00:12.465 INFO [org.keycloak.exportimport.util.ImportUtils] Realm 'backend-oauth' imported
2026-07-13T10:00:12.470 INFO [org.keycloak.services] KC-SERVICES0032: Import finished successfully
2026-07-13T10:00:12.722 INFO [io.quarkus] Keycloak 26.3.5 on JVM (powered by Quarkus 3.20.3) started in 11.329s. Listening on: http://0.0.0.0:7080.
The configuration file at examples/traffic-token-exchange/oauth-rfc8693/config.yaml
is remarkably concise: AgentGateway's built-in backendAuth.oauthTokenExchange
policy handles the RFC 8693 flow:
config: {}
binds:
- port: 3000
listeners:
- name: default
protocol: HTTP
routes:
- name: token-exchange
matches:
- path:
pathPrefix: /exchange
backends:
- host: localhost:18080
policies:
backendAuth:
oauthTokenExchange:
host: localhost:7080
tokenEndpointPath: /realms/backend-oauth/protocol/openid-connect/token
clientAuth:
clientId: requester-client
clientSecret: requester-secret
method: clientSecretBasic
audiences:
- target-client
Route match: Requests to /exchange
trigger token exchange:
matches:
- path:
pathPrefix: /exchange
Backend + policy: The backendAuth.oauthTokenExchange
block is where the magic happens:
host
tokenEndpointPath
: Where to send the exchange request (Keycloak's token endpoint)clientAuth
: The gateway authenticates to Keycloak as a confidential client (requester-client
), using HTTP Basic auth (clientSecretBasic
)audiences
: The target audience for the exchanged token: this becomes the aud
claim in the new JWTUnder the hood, AgentGateway:
Authorization: Bearer <token>
headergrant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token
audience=target-client
exp
)Authorization
header with the exchanged token before forwarding upstreamCompare this to the alternative extAuthz + CEL approach which requires ~40 lines of hand-written CEL expressions to achieve the same result. The built-in policy is the recommended approach.
Start AgentGateway with the token exchange configuration:
cargo run --release -- -f examples/traffic-token-exchange/oauth-rfc8693/config.yaml
Actual output:
2026-07-13T10:25:26.999303Z info agentgateway_app::commands::run version: {
"version": "d46bc5cc05429a9b3d16180ac9de4ef46e65e857",
"rust_version": "1.97.0",
"build_profile": "release",
"build_target": "x86_64-unknown-linux-gnu"
}
2026-07-13T10:25:27.001726Z info state_manager loaded config from File("examples/traffic-token-exchange/oauth-rfc8693/config.yaml")
2026-07-13T10:25:27.002446Z info agent_core::readiness Task 'agentgateway' complete (3.448656ms), still awaiting 1 tasks
2026-07-13T10:25:27.002522Z info agent_core::readiness Task 'state manager' complete (3.524277ms), marking server ready
2026-07-13T10:25:27.002588Z info proxy::gateway started bind bind="bind/3000"
The gateway hot-reloads the config file on save, so you can edit routes and params and re-curl without restarting it.
Leave this terminal running. Open a new terminal for testing.
Authenticate as the test user to get an initial JWT:
SUBJECT_TOKEN="$(curl -s http://localhost:7080/realms/backend-oauth/protocol/openid-connect/token \
-u initial-client:initial-secret \
-d grant_type=password \
-d username=testuser \
-d password=testpass | jq -r .access_token)"
echo "User token obtained: ${SUBJECT_TOKEN:0:50}..."
Actual output:
User token obtained: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6IC...
Decode the user token to inspect its claims:
PAYLOAD=$(echo $SUBJECT_TOKEN | cut -d'.' -f2)
MOD=$((${#PAYLOAD} % 4))
if [ $MOD -eq 2 ]; then PAYLOAD="${PAYLOAD}=="; elif [ $MOD -eq 3 ]; then PAYLOAD="${PAYLOAD}="; fi
echo $PAYLOAD | base64 -d 2>/dev/null | jq .
Actual output from my run:
{
"exp": 1783938629,
"iat": 1783938329,
"jti": "onrtro:a83f2b10-5d4e-6c1a-9e87-3b2c1d4e5f6a",
"iss": "http://localhost:7080/realms/backend-oauth",
"aud": "requester-client",
"typ": "Bearer",
"azp": "initial-client",
"sid": "8ced65e7-d7ba-4793-896c-ad3df3b09994",
"scope": ""
}
Key observations:
aud
= "requester-client"
: the audience is scoped for the gateway, not for any downstream serviceazp
= "initial-client"
: the authorized party (who requested this token)exp
iat
= 300 seconds: token expires in 5 minutessub
claim: this is a client credentials grant, not user-bound yetCall AgentGateway's /exchange
endpoint with the user's token:
curl -s http://localhost:3000/exchange \
-H "authorization: Bearer $SUBJECT_TOKEN"
Actual output (from the echo upstream: it reflects the request headers it received):
ServiceVersion=
ServicePort=8080
Host=localhost
URL=/exchange
Method=GET
Proto=HTTP/1.1
IP=10.89.2.3
RequestHeader=Accept:*/*
RequestHeader=Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSl...
RequestHeader=User-Agent:curl/7.76.1
Hostname=ed81d5bb758c
Notice the Authorization
header contains a different token than what I sent. The original user token had aud=requester-client
, but the upstream received a token with aud=target-client
. AgentGateway exchanged it transparently.
The gateway's access log confirms the exchange:
2026-07-13T10:25:29.848Z info request
gateway=default/default listener=default route=default/token-exchange
endpoint=localhost:18080 src.addr=[::1]:49202
http.method=GET http.path=/exchange http.status=200
protocol=http duration=17ms
The first request took 17ms: this includes the round-trip to Keycloak's token endpoint for the exchange.
To see exactly what changed, I performed the token exchange directly against Keycloak:
EXCHANGED_TOKEN="$(curl -s http://localhost:7080/realms/backend-oauth/protocol/openid-connect/token \
-u requester-client:requester-secret \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$SUBJECT_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=target-client" | jq -r .access_token)"
Decoded exchanged token (this is what the upstream service sees):
{
"exp": 1783938629,
"iat": 1783938329,
"jti": "ntrtte:5172a3d7-4def-7ec0-d6b1-4ad6adab761c",
"iss": "http://localhost:7080/realms/backend-oauth",
"aud": "target-client",
"sub": "feac01a0-48f7-4139-9b2d-171237c2f5e5",
"typ": "Bearer",
"azp": "requester-client",
"sid": "8ced65e7-d7ba-4793-896c-ad3df3b09994",
"scope": ""
}
| Claim | Original Token | Exchanged Token | Changed? |
|---|---|---|---|
iss |
|||
http://localhost:7080/realms/backend-oauth |
|||
| Same | No | ||
aud |
|||
"requester-client" |
|||
"target-client" |
|||
| Yes: audience now matches downstream | |||
sub |
|||
| (absent) | |||
"feac01a0-48f7-4139-9b2d-171237c2f5e5" |
|||
| Yes: user identity added | |||
azp |
|||
"initial-client" |
|||
"requester-client" |
|||
| Yes: shows who requested the exchange | |||
sid |
|||
"0ce5ad6d-..." |
|||
"8ced65e7-..." |
|||
| Varies per session | |||
jti |
|||
"onrtro:5447afe4-..." |
|||
"ntrtte:5172a3d7-..." |
|||
| Yes: new unique token ID |
Key takeaways:
sub
claim links to the same user (feac01a0-...
= testuser)aud
now matches the target service, not the gatewayazp
shows the gateway (requester-client
) requested this exchangejti
for audit trailsCall the endpoint multiple times with the same user token:
for i in {1..3}; do
curl -s -w "Request $i: %{time_total}s\n" -o /dev/null \
http://localhost:3000/exchange \
-H "authorization: Bearer $SUBJECT_TOKEN"
done
Actual output (these ran after the first /exchange call already populated the cache):
Request 1: 0.001003s <- cache hit
Request 2: 0.001595s <- cache hit
Request 3: 0.001129s <- cache hit
The gateway access logs confirm the difference between cache miss and cache hit:
duration=17ms
duration=0ms
duration=1ms
duration=0ms
The cache reduces per-request latency from 17ms to under 1ms. The TTL is capped by the subject JWT's exp
claim, so exchanged tokens are automatically refreshed when the original token expires.
Before Token Exchange (Insecure):
User -> [JWT: aud=requester-client] -> Gateway -> [Same JWT] -> Upstream
Problem: Upstream receives a token with the wrong audience, over-privileged scopes, and no indication that a gateway is involved.
After Token Exchange (Secure):
User -> [JWT: aud=requester-client] -> Gateway -> Keycloak exchange -> [JWT: aud=target-client] -> Upstream
Benefits:
aud=target-client
)sub
claim)azp
shows who performed the exchangeFor IdPs that support JWT bearer (e.g., Keycloak 26.5+ JWT Authorization Grant), change the grant type:
backendAuth:
oauthTokenExchange:
host: localhost:7080
tokenEndpointPath: /realms/backend-oauth/protocol/openid-connect/token
grantType: jwtBearer
clientAuth:
clientId: requester-client
clientSecret: requester-secret
method: clientSecretBasic
audiences:
- target-client
The key difference: the inbound credential is sent as assertion
instead of subject_token
.
Microsoft Entra does not speak RFC 8693. It uses a jwt-bearer variant with a vendor-specific requested_token_use=on_behalf_of
parameter:
backendAuth:
oauthTokenExchange:
host: login.microsoftonline.com:443
tokenEndpointPath: /<TENANT_ID>/oauth2/v2.0/token
grantType: jwtBearer
clientAuth:
clientId: <CLIENT_ID>
clientSecret: <CLIENT_SECRET>
method: clientSecretPost
scopes:
- https://graph.microsoft.com/.default
additionalParams:
requested_token_use: '"on_behalf_of"'
As Christian Posta notes: "SSO gets the user into the gateway; OBO gets the user out to Graph."
By default, the gateway reads the subject token from the Authorization: Bearer
header. You can change this:
subjectToken:
source:
header:
name: x-user-token
tokenType: urn:ietf:params:oauth:token-type:access_token
Put the exchanged token in a custom header instead of Authorization
:
authorizationLocation:
header:
name: x-upstream-auth
For RFC 8693 delegation (the act
claim), specify an actor token:
actorToken:
source:
header:
name: x-actor-token
tokenType: urn:ietf:params:oauth:token-type:jwt
The exchanged token will include an act
claim showing the agent's identity: auditable proof that "agent A called this API on behalf of user B."
To force every request to hit the token endpoint (useful for debugging):
cache:
inMemory:
maxEntries: 0
Symptom: AgentGateway returns 401 when calling /exchange
Check:
requester-client:requester-secret
matches Keycloak
echo $SUBJECT_TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .exp
date +%s # compare timestamps
http://localhost:7080/admin
, admin/admin), navigate to backend-oauth
realm -> Clients -> requester-client
-> Capability config, and verify "Standard Token Exchange" is enabledSymptom: Upstream echo shows the original token, not the exchanged one
Check: Verify the backendAuth
block is under backends[].policies
, not under routes[].policies
:
backends:
- host: localhost:18080
policies:
backendAuth:
oauthTokenExchange: ...
policies:
backendAuth: ...
backends:
- host: localhost:18080
Symptom: Container exits with "Failed to run import"
Fix: On SELinux-enabled systems (RHEL, Fedora), add the :Z
suffix to the volume mount:
-v ./backend-oauth-realm.json:/opt/keycloak/data/import/backend-oauth-realm.json:ro,Z
Without :Z
, SELinux blocks the container from reading the mounted file. This is what happened to me on RHEL 9 before I added the label.
pkill -f 'target/release/agentgateway'
podman rm -f backend-oauth-keycloak backend-oauth-upstream
podman network rm agw-token-exchange
I walked through implementing RFC 8693 token exchange in AgentGateway: from understanding the security problem to a working end-to-end setup with real Keycloak tokens. Here is what I covered:
backendAuth.oauthTokenExchange
handles the entire flow in ~10 lines of YAMLToken exchange is a critical security primitive for agentic AI. Without it, agents either see over-privileged tokens (security risk) or lose user identity (audit risk). AgentGateway makes it a configuration concern, not a code concern.
examples/traffic-token-exchange/extauthz/
for the hand-written version (useful when you need custom logic)examples/traffic-token-exchange/jwt-authz-grant/
for RFC 7523examples/mcp-authentication/
for agent-to-agent authThe tutorial above runs everything locally. When moving to production, keep these in mind:
Use environment variables for secrets. Never hardcode client secrets in YAML committed to version control:
clientAuth:
clientId: requester-client
clientSecret: $OAUTH_CLIENT_SECRET
method: clientSecretBasic
Kubernetes deployment. On Kubernetes, apply token exchange via AgentgatewayPolicy
targeting a Service:
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: okta-token-exchange
spec:
targetRefs:
- group: ""
kind: Service
name: my-backend
backend:
auth:
oauthTokenExchange:
tokenEndpoint:
group: agentgateway.dev
kind: AgentgatewayBackend
name: okta-token-endpoint
port: 443
path: /oauth2/default/v1/token
Monitor exchange latency. Token exchanges add latency on cache miss. Track:
Token lifetime tuning. Balance security vs. cache efficiency:
exp
: a safety net against stale tokensMulti-backend routing. Different backends can have different audiences. Define separate backendAuth
policies per backend:
routes:
- name: service-a
matches:
- path: { pathPrefix: /service-a }
backends:
- host: service-a.local
policies:
backendAuth:
oauthTokenExchange:
audiences: [service-a]
- name: service-b
matches:
- path: { pathPrefix: /service-b }
backends:
- host: service-b.local
policies:
backendAuth:
oauthTokenExchange:
audiences: [service-b]
Each backend receives a token scoped specifically for it.
About the Author: Marco Gonzalez (@mgonzalezo) is an AAIF Ambassador focusing on AgentGateway, MCP, Goose, and AGENTS.md. He contributes tutorials, blog posts, and PRs to the AAIF ecosystem.
License: This tutorial is licensed under CC-BY-4.0. Code examples are MIT licensed.
Happy Learning! 🚀