An AI agent that calls tools for a user needs that user’s authority, and the usual shortcuts are a pasted personal access token or a shared service account. The MCP authorization spec rules out passing a user’s token through to a server it was not issued for. agentgateway token exchange moves the problem to the gateway: the agent presents the user token it already has, agentgateway trades it with RFC 8693 token exchange, the RFC 7523 JWT bearer grant or Cross App Access (ID-JAG), and the backend receives a token scoped to itself that the agent never sees.
Disclosure: I work at Solo.io, which created agentgateway and sells Solo Enterprise for agentgateway. Everything below is open-source agentgateway unless it is labelled Solo Enterprise, and every claim links to the project’s own docs or release notes as of 2026-10-01.
Mode key used on this page: S is agentgateway standalone mode, K is Kubernetes mode, S+K is both.
The short answer: exchange the user’s token at the gateway
Put the exchange in the gateway. The agent carries only a token that says who the user is. When the agent calls a tool or API, agentgateway, with JWT authentication on the route, validates that token and swaps it for one scoped to that one backend, using the token endpoint of an authorization server you already trust. The backend sees a user-scoped credential and the agent process never sees it.
user --(login)--> IdP
user --(user JWT)--> agent (kagent or any client)
agent --(user JWT)--> agentgateway
1. jwtAuth: verify the user JWT
2. oauthTokenExchange: POST /token (subject_token = user JWT)
<-- authorization server returns a backend-scoped token
agentgateway --(backend token)--> MCP server / API
Who holds what: the agent holds the user JWT only. The gateway holds its own client credentials for the token endpoint and, briefly, the exchanged token. The backend gets the exchanged token, never the user JWT.
Why agents shouldn’t hold provider tokens
- The MCP spec limits which tokens a server may accept. The 2026-07-28 MCP authorization specification says MCP clients “MUST NOT send tokens to the MCP server other than ones issued by the MCP server’s authorization server” and MCP servers “MUST NOT accept or transit any other tokens”. It also requires servers to validate that a token was issued for them (RFC 8707 audience binding). I covered what the spec leaves open in the MCP agent identity gap .
- Blast radius. Anything in the agent’s context or environment can end up in a log, a trace or a prompt-injected tool call. A token the agent never receives can’t leak from the agent.
- Per-user audit. An exchanged token names the user (and, with delegation, the agent). A shared service account names neither.
What agentgateway token exchange does (both modes)
For static provider keys, see secretless AI agents , which injects one key at the gateway. This page is the per-user version of the same idea.
backendAuth.oauthTokenExchange (S+K) takes a credential from the incoming request, sends it to an authorization server’s token endpoint, and attaches the returned token to the upstream request before it leaves the gateway. History, from the release notes: RFC 8693 and RFC 7523 token exchange plus Cross App Access shipped in v1.4.0
(2026-07-27) with Kubernetes controller support; v1.4.1
made a missing subject token fail closed; v1.5.0
(2026-08-27) added per-leg scopes, a configurable subject token type, an optional requested_token_type and token exchange trace spans.
| Field | What it does | Doc |
|---|---|---|
grantType | tokenExchange (RFC 8693, default, sends subject_token) or jwtBearer (RFC 7523, sends assertion). K spells the enums TokenExchange / JwtBearer | S standard , S JWT bearer |
subjectToken | Where the incoming token comes from: header, query parameter, cookie or a CEL expression. Defaults to the Authorization bearer token, typed as an access token | S standard |
actorToken | Optional delegation token, token exchange grant only, no default source | K standard |
audiences, scopes, resources | Sent as audience, scope and resource (RFC 8707) | S standard |
clientAuth | How the gateway authenticates to the token endpoint: clientSecretBasic (default), clientSecretPost or privateKeyJwt | S standard |
additionalParams | Extra form parameters as CEL expressions | S JWT bearer |
cache | Exchanged tokens are cached; the standalone docs give 8192 entries with a 300-second TTL when the response omits expires_in (the Kubernetes docs give only the 8192 entries) | S standard |
Standalone mode (YAML, single binary)
Last checked 2026-10-01, agentgateway v1.5.0. This is the route I ran (T3 below). jwtAuth verifies the user JWT, then the exchange sends that raw token as the subject token. Because jwtAuth removes the validated credential from the request unless preserveToken: true is set (JWT authentication
), the subject token is read with jwt.rawToken.unredacted(). Hosts are my local mocks.
- name: t3-jwtauth-then-exchange
matches: [{path: {pathPrefix: /t3}}]
policies:
jwtAuth:
mode: strict
issuer: https://idp.example.test
audiences: [agent-client]
jwks: {file: ./keys/idp.jwks.json}
backends:
- host: 127.0.0.1:18080 # mock MCP backend
policies:
backendAuth:
oauthTokenExchange:
host: 127.0.0.1:7080 # mock authorization server
path: /token
clientAuth: {clientId: gateway-client, clientSecret: gateway-secret, method: clientSecretBasic}
audiences: [mcp-backend]
subjectToken:
source: {expression: 'jwt.rawToken.unredacted()'}
Since v1.4.1 (PR #2740
) the exchange fails closed when the configured subject-token source is missing or empty, with no implicit fallback, so select jwt.rawToken.unredacted() explicitly. The jwt.rawToken CEL variable itself arrived in v1.3.0
(PR #2027, by @mjungsbluth
, Magnus Jungsbluth of Zalando per his GitHub profile).
The JWT bearer variant in the Entra on-behalf-of shape, also run (T2):
backendAuth:
oauthTokenExchange:
host: 127.0.0.1:7080
path: /token
grantType: jwtBearer
clientAuth: {clientId: gateway-client, clientSecret: gateway-secret, method: clientSecretPost}
scopes: ["api://mcp-backend/.default"]
additionalParams:
requested_token_use: '"on_behalf_of"' # CEL string literal
The JWT bearer page documents this Entra example. Runnable upstream examples live in examples/traffic-token-exchange , and the agentgateway blog has a walkthrough of token exchange, JWT assertion and Entra OBO .
Kubernetes mode (AgentgatewayPolicy)
The same method as an AgentgatewayPolicy, abridged from the Kubernetes standard guide
:
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: backend-token-exchange
namespace: httpbin
spec:
targetRefs:
- group: ""
kind: Service
name: httpbin
backend:
auth:
oauthTokenExchange:
backendRef:
group: agentgateway.dev
kind: AgentgatewayBackend
name: keycloak-token-endpoint
path: /realms/backend-oauth/protocol/openid-connect/token
grantType: TokenExchange
audiences: [target-client]
clientAuth:
clientId: requester-client
method: ClientSecretBasic
secretRef:
name: oauth-client
The Kubernetes doc is explicit: “The exchange presents the incoming token to the authorization server exactly as it arrived, and does not verify the signature first.” Its fix is a route-level traffic.jwtAuthentication policy with preserveToken: true, after which an invalid token gets a 401 and the token endpoint is never called. For MCP servers, a Kubernetes-only page attaches the same oauthTokenExchange to an MCP AgentgatewayBackend instead of a plain Service (token exchange for MCP servers
).
Same feature, different mode: what changes
| Standalone (S) | Kubernetes (K) | |
|---|---|---|
| Config surface | backendAuth.oauthTokenExchange in the YAML config, camelCase enums | AgentgatewayPolicy spec.backend.auth.oauthTokenExchange, PascalCase enums, secrets via secretRef |
| Token endpoint | host + path | backendRef + path, or url |
| Where it attaches | A route backend | A Service or route target, or an MCP AgentgatewayBackend (K-only doc page) |
The docs list the same grants and fields in both modes. I ran standalone only, so treat Kubernetes runtime parity as documented, not tested.
Delegation: who is acting for whom (actor_token and act)
RFC 8693
distinguishes impersonation (the new token simply says “user”) from delegation (it says “user, with this actor acting”, expressed with the act claim). agentgateway exposes an optional actorToken (S+K) on the token exchange grant. It has no default source, so you point it at the header, query parameter, cookie or CEL expression that carries the agent’s own token. In my T5 run the gateway sent actor_token and actor_token_type as configured. The act claim in the result came from my mock authorization server: whether you get one depends on your authorization server, not on agentgateway.
Where does the agent’s own token come from? On OSS agentgateway v1.5.0 standalone, I covered SPIFFE identity for AI agents . For the kagent client side of RFC 8693 with RFC 8707 audiences, see kagent audience-bound agent tokens (that post’s harness is a stand-in for the kagent runtime). For federating a Kubernetes service account token to a cloud provider instead, see workload identity federation for agents . For how the two divide the work, SPIFFE for who the agent is, OAuth for what it may do, see SPIFFE vs OAuth for AI agents .
Solo Enterprise aside, labelled: Solo Enterprise for agentgateway has a built-in token service that issues on-behalf-of tokens with an act claim (delegation doc
; its steps deploy the agent with kagent). I wrote up running that token service as a standalone binary in agent token service, standalone
, also Solo Enterprise for agentgateway. Neither is open-source evidence for this page.
Cross App Access (ID-JAG): enterprise-managed authorization for MCP
Cross App Access (ID-JAG, often shortened to XAA), also described as MCP Enterprise-Managed Authorization
, is two legs: an RFC 8693 exchange at the enterprise IdP for an Identity Assertion JWT, then an RFC 7523
JWT bearer grant at the resource’s authorization server. agentgateway (S+K) runs both legs. It needs jwtAuth validating an OIDC ID token, and since v1.5.0 each leg can carry its own scopes. See the standalone
and Kubernetes
pages and examples/traffic-cross-app-access
. I have not run it against a specific IdP.
When your IdP isn’t enough: agent identity brokers
Native exchange works when your IdP or authorization server can mint the backend token. For third-party providers such as a SaaS API with its own OAuth server, your IdP can’t mint that token. Someone has to collect the user’s consent once, hold the resulting provider token, and release it per request. That is the job of an agent identity broker.
Case study: Zalando’s Agentic Identity Broker on kagent and agentgateway
On 2026-09-25 Zalando open-sourced its Agentic Identity Broker (post by Jan Brennenstuhl and Magnus Jungsbluth; MIT-licensed Go repo ). Per Zalando’s post and docs:
- What it is. A delegation and consent broker, not an IdP. It relies on a reverse proxy for user authentication (why not an IdP
). It models permission sets, grants (revocable, optionally time-limited) and user sessions that hold encrypted provider tokens, and it exposes RFC 8693 on its token endpoint, where
resourceselects which provider credential to use (token exchange concepts ). - What it uses from agentgateway. Its gateway guide
configures agentgateway v1.5.0
jwtAuthin strict mode plus anextProcpolicy withfailureMode: failClosed, passingjwt.rawToken.unredacted()and the resource URI as metadata. The broker’s ownextproc-token-exchangesidecar performs the exchange and replacesAuthorization. It does not use agentgateway’s nativeoauthTokenExchange. Zalando also wired Open Policy Agent into agentgateway through the same external processing hook. - What it uses from kagent. Zalando’s platform combines kagent and agentgateway with the broker, and uses kro to translate its platform CRDs into kagent and agentgateway resources.
- Stated limits. “A revoked or expired grant blocks future exchanges, but it cannot revoke a provider token that has already been issued.” CIBA and SPIFFE support are on their list of next steps.
I described the broker from its public docs. I didn’t run it.
Native exchange vs broker on the same gateway (facts, not advice)
| Path on agentgateway | Who mints the backend token | Where provider tokens live | Consent | Revocation | Mode |
|---|---|---|---|---|---|
Native oauthTokenExchange (OSS) | Your IdP or authorization server | Not held; minted per exchange | Your IdP’s | Your IdP’s; exchanged tokens are cached by the gateway | S+K |
Broker via extProc (Zalando, as documented) | The provider, via the broker | Encrypted in the broker’s sessions | Per user and agent grant in the broker | Grant revocation stops future exchanges; issued tokens live until expiry (Zalando) | S as published |
| Built-in token service (Solo Enterprise for agentgateway) | Solo Enterprise token service | Not covered here | Not covered here | Not covered here | K per the Solo doc |
The full series lives at agent identity .
Gotchas
- Verify before you exchange. Without
jwtAuthon the route, a forged token went to the authorization server in my standalone test (T3), matching the Kubernetes doc’s warning. The standalone doc doesn’t carry that warning today. - Name the subject token source. A missing source fails closed with a 400 and no call to the authorization server (T4, and the v1.4.1 notes).
- Cache vs revocation. A repeat request with the same user token reused the cached exchange in T1. The standalone docs’ 300-second default applies only when the token response omits
expires_in. I didn’t measure a revocation window. actorTokenhas no default source. Name it explicitly.- Entra. v1.4.0 added a native
entraMCP auth provider that serves RFC 8414 metadata from OIDC discovery, strips the RFC 8707resourceparameter and short-circuits dynamic client registration (v1.4.0 notes ).
Run it yourself
I ran the released agentgateway v1.5.0 Linux binary (sha256 daca5cda76e8c5ab0c1a75912fecf2d6365095403f810db72029c49d14a37e7b, git revision fe67324) in standalone mode on 2026-10-01 at 10:17 AM PT, and re-ran the same script at 10:35 AM PT with the header capture below scripted. Every status code and authorization server call count matched. The authorization server and backend were small Python mocks, not Keycloak or Entra. The mock authorization server does not verify the subject token and adds act itself. The backend logs the claims it receives and returns only a fingerprint of the token. The machine had no Docker, so nothing ran in Kubernetes mode.
The T1 client-side check is a response-header capture, recorded in the harness’s run-tests.sh:
curl -s -D evidence/t1-client-response-headers.txt -o /dev/null \
http://127.0.0.1:3000/t1 -H "Authorization: Bearer $(python3 mock/mint.py user)"
| Test | What I checked | Result | Grade |
|---|---|---|---|
| T1 | RFC 8693, default subject source | 200. Authorization server got grant_type token-exchange, subject_token, audience, scope, resource, Basic client auth. Backend got a different token (aud mcp-backend). Client response carried no token (by mock design: the mock backend returns only a fingerprint). Repeat request reused the cache (one authorization server call) | Tested (S) |
| T2 | JWT bearer, Entra OBO shape | 200. Authorization server got grant_type jwt-bearer, assertion, requested_token_use=on_behalf_of, client secret in the form, scope | Tested (S), mock authorization server |
| T3 | Forged token without and with jwtAuth | Without (route /t1, default subject source): 200, forwarded unverified. With strict jwtAuth (route /t3, subject from jwt.rawToken.unredacted()): 401 InvalidSignature, authorization server not called. Valid token with jwtAuth: 200 | Tested (S) |
| T4 | Missing subject token | 400 for a missing configured header and for no Authorization. Authorization server not called | Tested (S) |
| T5 | actorToken from a header | 200. Authorization server got actor_token and actor_token_type jwt. act came from the mock | Tested (S), gateway side only |
| T1, T3 in K | Kubernetes mode | Not run | Documented: K standard |
| Cross App Access, real IdP, revocation timing, broker | Not run | Documented only |
Methodology and disclosure
Checked 2026-10-01 (PT) against agentgateway docs labelled 1.5 (latest) in both modes, release notes v1.3.0 through v1.5.0, the v1.5.0 config schema, RFCs 8693, 7523 and 8707, and the MCP authorization specification (2026-07-28). Tests ran on agentgateway v1.5.0 standalone only, as described above. Zalando’s broker is described from its public post, repo and docs; I didn’t run it. Solo Enterprise material is labelled and is not used as open-source evidence. Re-check when agentgateway v1.6.0 is generally available.
Disclosure: I work at Solo.io, which created agentgateway and sells Solo Enterprise for agentgateway. Everything above is open-source agentgateway unless it is labelled Solo Enterprise, and every claim links to the project’s own docs or release notes as of 2026-10-01.