For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Standard token exchange (RFC 8693)
Verified Code examples on this page have been automatically tested and verified.Exchange the incoming request credential for a per-backend token with the RFC 8693 token exchange grant.
Attaches to:
Exchange the incoming token for a backend-scoped token with the RFC 8693 token exchange grant.
About
The tokenExchange grant is the default grant of the oauthTokenExchange backend authentication method. The gateway sends the incoming token to the authorization server as the subject_token and forwards the exchanged token to the backend.
For the JWT bearer grant, which sends the incoming token as an assertion instead, see JWT bearer grant. For an exchange that crosses a trust boundary between two authorization servers, see Cross App Access.
Before you begin
The following examples run against local Keycloak stacks from the agentgateway repository. Make sure that you have the following tools installed:
Docker and Docker Compose
jqfor reading token responsesA local clone of the agentgateway repository, which contains the example stacks:
git clone https://github.com/agentgateway/agentgateway.git cd agentgateway
Exchange a token
In this example, a user authenticates to Keycloak as one client, and the gateway exchanges that token for a token scoped to a different backend client.
Start the example stack. The stack runs Keycloak on port
7080with thebackend-oauthrealm pre-seeded, and an echo upstream on port18080that reflects the request headers it receives.docker compose -f examples/traffic-token-exchange/oauth-rfc8693/docker-compose.yaml up -dReview the gateway configuration. The
oauthTokenExchangemethod points at the Keycloak token endpoint, authenticates as the confidential clientrequester-client, and requests a token foraudience=target-client. BecausegrantTypeis omitted, the gateway uses the default RFC 8693 token exchange grant. For the full set of fields, see the configuration reference.# Exercises the backendAuth.oauth token-exchange policy # (as opposed to the extAuthz+CEL approach in ../extauthz). # # Uses docker-compose.yaml in this directory: # - Keycloak realm "backend-oauth" on :7080 # - echo upstream on :18080 config: {} binds: - port: 3000 listeners: - name: default protocol: HTTP routes: # RFC 8693 token exchange: inbound user bearer -> per-upstream token - name: token-exchange matches: - path: pathPrefix: /exchange backends: - host: localhost:18080 policies: backendAuth: oauthTokenExchange: host: localhost:7080 path: /realms/backend-oauth/protocol/openid-connect/token clientAuth: clientId: requester-client clientSecret: requester-secret method: clientSecretBasic audiences: - target-clientSave the configuration to a file and run agentgateway.
agentgateway -f config.yamlIn another terminal, mint a user token from Keycloak to use as the incoming token.
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)"Send a request to the gateway with the token. The gateway exchanges the token and forwards the request to the echo upstream, which reflects the headers it received.
curl -s http://localhost:3000/exchange -H "authorization: Bearer $SUBJECT_TOKEN"In the response, note that the
Authorizationheader forwarded to the upstream contains a different token than the one you sent.... URL=/exchange Method=GET RequestHeader=Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI... ...Copy the exchanged token from the
Authorizationheader in the previous response, and save it to an environment variable.export FORWARDED_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI...Decode the token’s payload to confirm the exchange. This command splits off the JWT payload segment and decodes it with
jq.echo "$FORWARDED_TOKEN" | cut -d. -f2 | jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson'The exchanged token is issued by the
backend-oauthrealm foraud=target-client, and its authorized party (azp) is the gateway’srequester-client, not the originalinitial-client.{ "exp": 1783970031, "iat": 1783969731, "jti": "ntrtte:de1c05c3-64bb-999c-80ce-a5f165570c14", "iss": "http://localhost:7080/realms/backend-oauth", "aud": "target-client", "sub": "92e9b475-282b-4ec9-97f3-cc115ab69b70", "typ": "Bearer", "azp": "requester-client", "sid": "17cbdd1f-d8f0-48b8-9c7f-460fda591c69", "scope": "" }
More examples
The traffic-token-exchange examples in the agentgateway repository also include an extauthz example that performs a token exchange by building the token request by hand with external authorization and CEL, as an alternative to the built-in oauthTokenExchange method.
Custom headers
To read the incoming token from a custom location and place the exchanged token somewhere other than the Authorization header, update the source header.
backendAuth:
oauthTokenExchange:
host: idp.example.com:443
path: /token
# Read the incoming token from a custom header and declare its token type.
subjectToken:
tokenType: urn:ietf:params:oauth:token-type:jwt
source:
header:
name: x-subject-token
prefix: "Bearer "
# Place the exchanged token in a custom header instead of Authorization.
authorizationLocation:
header:
name: x-upstream-auth
prefix: "Bearer "Actor tokens
For the RFC 8693 token exchange grant only, an actor token can be sent for delegation (actor_token / actor_token_type). Unlike the subject token, the actor token has no default source, so a source must be set.
backendAuth:
oauthTokenExchange:
host: idp.example.com:443
path: /token
actorToken:
tokenType: urn:ietf:params:oauth:token-type:access_token
source:
header:
name: x-actor-token
prefix: "Bearer "Authenticate the gateway to the token endpoint
The clientAuth field decides how agentgateway identifies itself to the token endpoint. Set method to one of three values. Omit clientAuth entirely and agentgateway sends no client authentication fields, which suits a public client or a token endpoint that authenticates by other means.
Send the client ID and secret in the HTTP Basic Authorization header (RFC 6749 §2.3.1). This is the default method, and the one most authorization servers expect.
clientAuth:
method: clientSecretBasic
clientId: $CLIENT_ID
clientSecret: $CLIENT_SECRETSign the client assertion with a private key
Use privateKeyJwt when the authorization server registers your client with a public key rather than a secret. Register the matching public key or certificate with the authorization server first, then point the signingKey field at the private key. This method is often required for a confidential client that must not hold a shared secret. Servers such as Okta and Microsoft Entra both support this method.
| Field | Description |
|---|---|
clientId | Required client ID that identifies agentgateway at the authorization server. |
signingKey | Required PEM-encoded RSA or EC private key, either the PEM text or {file: <path>}. |
assertionAudience | Required aud claim of the assertion. Most servers require the URL of the token endpoint itself. |
alg | JWS signing algorithm: RS256 (default), RS384, RS512, PS256, ES256, or ES384. The algorithm must match the key family. The RS and PS algorithms need an RSA key, and the ES algorithms need an EC key. |
kid | Optional kid header that agentgateway stamps on the assertion. Set it when the authorization server registers more than one key for the client. |
certificate | Optional PEM-encoded X.509 certificate chain, leaf first. Set it for a server that validates the assertion against a certificate rather than a bare key. |
certificateHeader | JWS certificate header that agentgateway emits from certificate. Required when certificate is set. |
Note
The privateKeyJwt method is not the same as the jwtSign backend authentication method. Both sign a JWT with your private key, and they share the implementation, but privateKeyJwt authenticates agentgateway to the token endpoint, while jwtSign sends a signed JWT to the backend itself.
Two details are worth knowing before you rely on the method.
- The method names are camelCase here. The Kubernetes custom resources spell the same values in PascalCase, as
ClientSecretBasic,ClientSecretPost, andPrivateKeyJwt. Agentgateway rejects the PascalCase spelling. - The leaf public key of
certificatemust matchsigningKey. A mismatch is logged and does not stop the configuration from loading, so the failure appears as a rejected assertion at the token endpoint rather than as a startup error.
Configuration reference
The following table describes the most common oauthTokenExchange fields. For the full set of fields, see oauthTokenExchange in the API reference for Kubernetes. The two modes take the same fields; standalone spells the enum values in camelCase, such as jwtBearer rather than JwtBearer.
| Field | Description |
|---|---|
host, policies | The token endpoint, referenced as a backend. A host port of 443 automatically enables backend TLS. |
path | Path of the token endpoint on the backend. Must start with /. Defaults to /. |
grantType | tokenExchange (default, RFC 8693) or jwtBearer (RFC 7523). |
clientAuth | Client authentication for the token endpoint: clientSecretBasic (default), clientSecretPost, or privateKeyJwt. Omit the field and agentgateway sends no client authentication. See Authenticate the gateway to the token endpoint. |
audiences, scopes, resources | The audience, scope, and resource parameters sent to the token endpoint. resources are RFC 8707 resource indicators. |
subjectToken | Where to read the incoming token and its token type. Defaults to the Authorization: Bearer header with token type access_token. |
actorToken | Optional RFC 8693 delegation actor token (tokenExchange grant only). Has no default source. |
authorizationLocation | Where to place the exchanged token in the backend request. Defaults to the Authorization header with a Bearer prefix. |
additionalParams | Extra form parameters appended to the token request. Values are CEL expressions. |
cache | In-memory token cache. Defaults to 8192 entries with a 300-second TTL when the response omits expires_in. Set maxEntries: 0 to disable. |
Next steps
- Read the Shielding AI agents from sensitive credentials blog post for a walkthrough of token exchange, JWT assertion, and Entra on-behalf-of.
- Validate incoming JWTs with the JWT authentication policy.
Cleanup
Stop the gateway with Ctrl+C, then remove the example stack.
docker compose -f examples/traffic-token-exchange/oauth-rfc8693/docker-compose.yaml down