Skip to content
agentgateway has joined the Agentic AI Foundation — Learn more

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.
Page as Markdown

Exchange the incoming request credential for a per-backend token with the RFC 8693 token exchange grant.

Exchange the incoming token for a backend-scoped token with the RFC 8693 token exchange grant, configured on an AgentgatewayPolicy.

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.

In this guide, one Keycloak instance plays both roles: the authorization server that mints the exchanged token, and, after you add edge validation, the issuer that the gateway validates the incoming token against.

    flowchart LR
    Client -- "1. Keycloak JWT" --> AGW[Agentgateway]
    AGW -- "2. validate against JWKS" --> KC["Keycloak<br>(validator + token endpoint)"]
    AGW -- "3. exchange (RFC 8693 subject_token)" --> KC
    KC -- "exchanged token" --> AGW
    AGW -- "4. Authorization: Bearer<br>exchanged token" --> Backend[httpbin]
  

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

  1. Follow the Get started guide to install agentgateway.

  2. Follow the Sample app guide to create a gateway proxy with an HTTP listener and deploy the httpbin sample app.

  3. Get the external address of the gateway and save it in an environment variable.

    Tip

    Kind cluster? Kind does not support LoadBalancer services by default. To use this option with a Kind cluster, install and run cloud-provider-kind.

    export INGRESS_GW_ADDRESS=$(kubectl get svc -n agentgateway-system agentgateway-proxy -o jsonpath="{.status.loadBalancer.ingress[0]['hostname','ip']}")
    echo $INGRESS_GW_ADDRESS  

Deploy Keycloak

Deploy a Keycloak authorization server into your cluster to act as the token endpoint. This example imports two realms so that you can exercise both grants:

  • backend-oauth: The resource realm that performs the exchange. It has an initial-client (mints the user’s inbound token for the RFC 8693 grant), a confidential requester-client (the gateway’s client, with token exchange enabled), a target-client audience, and testuser / testpass user credentials.
  • idp: A separate identity provider realm that issues the assertion for the RFC 7523 JWT bearer grant. The backend-oauth realm trusts it through a JWT Authorization Grant identity provider.

Steps to deploy Keycloak:

  1. Download the realm definitions and load them into a ConfigMap in the httpbin namespace, alongside the sample app. The sed command rewrites the issuer host in the import (which is pinned to localhost:7080 for local Docker use) to the in-cluster Keycloak address, so that the realms trust each other when Keycloak runs in the cluster.

    BASE=https://agentgateway.dev/examples/traffic-token-exchange/jwt-authz-grant/jwtbearer-import
    for realm in backend-oauth-realm idp-realm; do
      curl -sL "$BASE/$realm.json" \
        | sed 's#http://localhost:7080#http://keycloak.httpbin.svc.cluster.local:8080#g' \
        > "$realm.json"
    done
    
    kubectl create configmap backend-oauth-realm -n httpbin \
      --from-file=backend-oauth-realm.json \
      --from-file=idp-realm.json
  2. Deploy Keycloak and its Service into the httpbin namespace. The --features=preview flag enables Keycloak’s JWT Authorization Grant, which the RFC 7523 JWT bearer grant requires. The KC_HOSTNAME variable pins the token issuer to the in-cluster DNS name, so that tokens minted through a port-forward and the gateway’s token-exchange call agree on the issuer (iss). Without this, Keycloak rejects the token with an issuer mismatch.

    kubectl apply -f- <<EOF
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: keycloak
      namespace: httpbin
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: keycloak
      template:
        metadata:
          labels:
            app: keycloak
        spec:
          containers:
          - name: keycloak
            image: quay.io/keycloak/keycloak:26.7.1
            args: ["start-dev", "--import-realm", "--http-port=8080", "--features=preview"]
            env:
            - name: KC_BOOTSTRAP_ADMIN_USERNAME
              value: admin
            - name: KC_BOOTSTRAP_ADMIN_PASSWORD
              value: admin
            - name: KC_HOSTNAME
              value: "http://keycloak.httpbin.svc.cluster.local:8080"
            - name: KC_HOSTNAME_STRICT
              value: "false"
            - name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC
              value: "false"
            ports:
            - containerPort: 8080
            volumeMounts:
            - name: realm
              mountPath: /opt/keycloak/data/import
              readOnly: true
          volumes:
          - name: realm
            configMap:
              name: backend-oauth-realm
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: keycloak
      namespace: httpbin
    spec:
      selector:
        app: keycloak
      ports:
      - name: http
        port: 8080
        targetPort: 8080
    EOF
  3. Wait for Keycloak to be ready.

    kubectl rollout status deployment/keycloak -n httpbin --timeout=180s

Configure token exchange

Configure agentgateway to exchange tokens.

  1. Create an AgentgatewayBackend for the token endpoint, pointing at the in-cluster Keycloak Service.

    kubectl apply -f- <<EOF
    apiVersion: agentgateway.dev/v1alpha1
    kind: AgentgatewayBackend
    metadata:
      name: keycloak-token-endpoint
      namespace: httpbin
    spec:
      static:
        host: keycloak.httpbin.svc.cluster.local
        port: 8080
    EOF
  2. Create a Kubernetes Secret with the gateway client’s secret. This matches the requester-client secret from the imported realm.

    kubectl apply -f- <<EOF
    apiVersion: v1
    kind: Secret
    metadata:
      name: oauth-client
      namespace: httpbin
    type: Opaque
    stringData:
      clientSecret: requester-secret
    EOF
  3. Create an AgentgatewayPolicy that attaches the oauthTokenExchange method to the httpbin Service. The backendRef field references the AgentgatewayBackend, path sets the token endpoint path, and grantType selects the RFC 8693 exchange.

    kubectl apply -f- <<EOF
    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
    EOF

    Review the following table to understand this configuration. For more information, see the API docs.

    FieldDescription
    backendRefReference to the AgentgatewayBackend for the token endpoint. Mutually exclusive with url. Set exactly one of the two.
    urlThe full address of the token endpoint, including the path. Use this field instead of backendRef to point at the authorization server directly, without creating an intermediate Kubernetes object. Mutually exclusive with backendRef. Do not set path when you use url.
    pathPath of the token endpoint on the backend. Must start with /. Defaults to /.
    grantTypeTokenExchange (default, RFC 8693) or JwtBearer (RFC 7523).
    clientAuthClient authentication for the token endpoint. method is ClientSecretBasic (default), ClientSecretPost, or PrivateKeyJwt. Use secretRef to read the client secret from a Kubernetes Secret.
    audiences, scopes, resourcesThe audience, scope, and resource parameters sent to the token endpoint. resources are RFC 8707 resource indicators.
    subjectToken.sourceWhere the gateway reads the incoming token from. Set exactly one of header, queryParameter, cookie, or expression, where expression is a CEL expression that reads the token from the request, such as a claim of a validated JWT. Defaults to the Authorization header with the Bearer prefix.
    subjectToken.tokenTypeThe type that the gateway reports for that token. Use a built-in name such as AccessToken (the default), Jwt, or IdToken, or a custom absolute URI. See Token types.
    actorTokenOptional RFC 8693 delegation actor token (TokenExchange grant only). Takes the same tokenType values as subjectToken.
    requestedTokenTypeOptional token type to request, limited to AccessToken, Jwt, or IdToken, and valid only with the TokenExchange grant type. The response must return the type that you request. See Request a token type.
    locationWhere to place the exchanged token in the backend request. Defaults to the Authorization header.
    additionalParamsExtra form parameters appended to the token request. Values are CEL expressions.
    cacheIn-memory token cache. Defaults to 8192 entries. Set inMemory.maxEntries: 0 to disable.
  4. Confirm that the policy is accepted and attached.

    kubectl -n httpbin get AgentgatewayPolicy backend-token-exchange -o jsonpath='{.status.ancestors[0].conditions[*].type}={.status.ancestors[0].conditions[*].status}{"\n"}'

    Example output:

    Accepted Attached=True True

Verify the exchange

Mint the incoming token, send a request through agentgateway with it, and verify that the token the gateway forwards is a different one: it is issued for the target-client audience with requester-client as the authorized party (azp), not the client that minted the incoming token.

  1. Port-forward the Keycloak Service so that you can reach its token endpoint locally.

    kubectl port-forward -n httpbin svc/keycloak 8080:8080
  2. In another terminal, mint the incoming token. Mint a user token from the backend-oauth realm as initial-client; the gateway sends this as the subject_token. Tokens expire, so re-mint if you come back later.

    export INBOUND_TOKEN="$(curl -s http://localhost:8080/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 $INBOUND_TOKEN
  3. Send a request to the httpbin /headers endpoint through the gateway, with the incoming token. The gateway exchanges the token at Keycloak and forwards the request to httpbin with the exchanged token. Because httpbin reflects the request headers, you can see the token that the gateway forwarded.

    curl -s http://$INGRESS_GW_ADDRESS:80/headers \
      -H "host: www.example.com" \
      -H "authorization: Bearer $INBOUND_TOKEN"

    In the response, note that the Authorization header reflected by httpbin contains a different token than the one you sent.

  4. Extract the exchanged token from the reflected Authorization header and decode its payload, to confirm the exchange.

    curl -s http://$INGRESS_GW_ADDRESS:80/headers \
      -H "host: www.example.com" \
      -H "authorization: Bearer $INBOUND_TOKEN" \
      | jq -r '.headers.Authorization | sub("^Bearer ";"")' \
      | cut -d. -f2 \
      | jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson'

    The decoded token was issued for the target audience (aud), and its authorized party (azp) is the gateway’s client (requester-client), not the client that minted the incoming token. The sub claim still identifies testuser, so the backend sees the same end user.

    {
      "iss": "http://keycloak.httpbin.svc.cluster.local:8080/realms/backend-oauth",
      "aud": "target-client",
      "azp": "requester-client",
      "sub": "de07f63e-1e3e-4c17-ba99-882b1954fb04"
    }

Validate the incoming token at the edge

The exchange presents the incoming token to the authorization server exactly as it arrived, and does not verify the signature first. Add a route-level jwtAuthentication policy so that an invalid or expired token is rejected at the gateway before any call to the token endpoint. The preceding steps leave it out so that the exchange is easy to follow on its own; add it before you use token exchange in production.

  1. Create a second AgentgatewayPolicy that validates the incoming token against Keycloak’s JWKS. This policy targets the HTTPRoute, not the Service, because validation belongs at the route.

    Important

    Set preserveToken: true. By default the gateway removes the JWT after it validates it, so the exchange finds no subject_token and every request fails with a 400 and the message invalid request. For more information, see preserveToken.

    kubectl apply -f- <<EOF
    apiVersion: agentgateway.dev/v1alpha1
    kind: AgentgatewayPolicy
    metadata:
      name: jwt-edge
      namespace: httpbin
    spec:
      targetRefs:
      - group: gateway.networking.k8s.io
        kind: HTTPRoute
        name: httpbin
      traffic:
        jwtAuthentication:
          preserveToken: true
          providers:
          - issuer: "http://keycloak.httpbin.svc.cluster.local:8080/realms/backend-oauth"
            jwks:
              remote:
                jwksPath: /realms/backend-oauth/protocol/openid-connect/certs
                backendRef:
                  group: ""
                  kind: Service
                  name: keycloak
                  port: 8080
    EOF
  2. Send the same request again with the valid token from the previous section. The exchange still runs, and httpbin still reflects the exchanged token.

    curl -s http://$INGRESS_GW_ADDRESS:80/headers \
      -H "host: www.example.com" \
      -H "authorization: Bearer $INBOUND_TOKEN"
  3. Send a request with a token that does not validate. The gateway rejects it with a 401, and never calls the token endpoint.

    curl -s http://$INGRESS_GW_ADDRESS:80/headers \
      -H "host: www.example.com" \
      -H "authorization: Bearer not-a-valid-token"

    Example output:

    authentication failure: the token header is malformed: Error(InvalidToken)
  4. Send a request with no token at all. The gateway rejects this case too.

    curl -s http://$INGRESS_GW_ADDRESS:80/headers -H "host: www.example.com"

    Example output:

    authentication failure: no bearer token found

Token types

The subjectToken.tokenType and actorToken.tokenType fields accept a built-in name (AccessToken, Jwt, or IdToken), or any absolute URI without a fragment for an authorization server that defines its own token exchange profile. The gateway expands a built-in name to its urn:ietf:params:oauth:token-type:* form, and passes a custom URI through unchanged.

For example, set subjectToken.tokenType to the type that your authorization server expects.

subjectToken:
  source:
    header:
      name: authorization
      prefix: "Bearer "
  tokenType: "urn:company:domain:human"

The gateway sends that value verbatim as the subject_token_type form parameter. An actorToken.tokenType value travels the same way, as actor_token_type.

An invalid value is rejected after you apply the policy, not by the API server. The policy reports Accepted: True with the reason PartiallyValid and the following message, and the data plane refuses it, so every request on the route fails with a 400 and the message invalid request.

oauth subjectToken tokenType "not a uri" must be a built-in token type or an absolute URI without a fragment

Request a token type

Unlike the subject and actor token types, requestedTokenType is a closed set. Only AccessToken, Jwt, and IdToken can be requested, and the API server rejects the policy otherwise.

ValueResult
A custom URIUnsupported value: "urn:company:domain:human": supported values: "AccessToken", "Jwt", "IdToken", "IdJag"
IdJagrequestedTokenType IdJag is only supported by crossAppAccess. The value appears in the list because the type list is shared with Cross App Access.
Any value, with the JwtBearer grant typerequestedTokenType is only valid with TokenExchange grantType

When you set requestedTokenType, the gateway sends requested_token_type on the token request, then compares the issued_token_type of the response against it. A mismatch fails the exchange with a 500, and the request never reaches the backend.

backend authentication failed: token exchange returned issued_token_type urn:ietf:params:oauth:token-type:jwt, expected urn:ietf:params:oauth:token-type:access_token

Note

When you omit requestedTokenType, the gateway sends no requested_token_type parameter, and it does not check issued_token_type at all. The authorization server chooses the type, and the gateway accepts whatever the response returns. Set the field when the type of the issued token matters to the backend.

Support for non-compliant providers

The gateway accepts the following three settings for compatibility with providers that do not accept the standards-compliant forms. Each one logs a warning in the proxy and still forwards traffic. A future release might reject them, so prefer RFC 8707 resource URIs, valid scope tokens, and header or cookie placement where your provider allows it.

SettingWarning in the proxy log
A resources entry that is not an absolute URI without a fragmentoauth token exchange resource is not an absolute URI without a fragment
A scopes entry with characters outside the RFC 6749 scope-token grammar, such as a space, a quotation mark, a backslash, a control character, or a non-ASCII characteroauth token exchange scopes contains an invalid OAuth scope-token
location.queryParameter, which carries the exchanged token in a URI query parameteroauth token exchange is configured to forward the exchanged bearer token in a URI query parameter

Troubleshooting

subject_token validation failure

What’s happening:

The token endpoint returns an invalid_token or invalid_request error, and the gateway responds with HTTP 400. The authorization server logs a subject_token validation failure.

Why it’s happening:

The authorization server cannot validate the incoming token, often because the token’s issuer (iss) does not match the issuer that the authorization server expects when the gateway reaches it. With Keycloak, this happens when the token is minted through one hostname (for example, a port-forward) but the gateway calls the token endpoint through a different in-cluster hostname.

How to fix it:

Make sure the incoming token’s issuer matches the token endpoint’s issuer as the gateway reaches it. For Keycloak in a cluster, pin the issuer with the KC_HOSTNAME environment variable so it is stable regardless of how Keycloak is reached.

Next steps

This guide uses a demo Keycloak and the httpbin sample app. To use token exchange in production:

  • Point at your own authorization server. Create an AgentgatewayBackend for your IdP (such as Keycloak, Microsoft Entra, Okta, Auth0, or ZITADEL). Use port 443 for automatic backend TLS. Replace the demo realm, client IDs, audiences, and Kubernetes Secret with your own.
  • Attach the policy to the backends that need scoped tokens. Target the AgentgatewayPolicy at the Services or AgentgatewayBackends that require their own credential, such as MCP servers, upstream APIs, or LLM providers. Pair it with route-level JWT authentication to validate the incoming token first.
  • Use token exchange to preserve agent and user identity. Token exchange lets the gateway hand each backend a narrowly scoped, per-backend token while preserving the caller’s identity end-to-end. In agentic flows, the exchange can carry an agent acting on behalf of a user, so every downstream call keeps an auditable, least-privilege identity chain instead of sharing one broad credential.

Cleanup

kubectl delete AgentgatewayPolicy backend-token-exchange jwt-edge -n httpbin
kubectl delete AgentgatewayBackend keycloak-token-endpoint -n httpbin
kubectl delete secret oauth-client -n httpbin
kubectl delete deployment keycloak -n httpbin
kubectl delete service keycloak -n httpbin
kubectl delete configmap backend-oauth-realm -n httpbin
Was this page helpful?
Agentgateway assistant

Ask me anything about agentgateway configuration, features, or usage.

Note: AI-generated content might contain errors; please verify and test all returned information.

Tip: one topic per conversation gives the best results. Use the + button in the chat header to start a new conversation.

Switching topics? Starting a new conversation improves accuracy.
↑↓ navigate ↵ select esc dismiss

What could be improved?

Your feedback helps us improve assistant answers and identify docs gaps we should fix.

Need more help? Join us on Discord: https://discord.gg/y9efgEmppm

Want to use your own agent? Add the Solo MCP server to query our docs directly. Get started here: https://search.solo.io/.