For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
JWT bearer grant (RFC 7523)
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 7523 JWT bearer grant.
Exchange the incoming token for a backend-scoped token with the RFC 7523 JWT bearer grant, configured on an AgentgatewayPolicy.
About
The JwtBearer grant sends the incoming token to the authorization server as the assertion rather than as the subject_token. Use it when the incoming token is a JWT issued by an identity provider that the authorization server trusts, but that did not itself issue the backend token.
For the default RFC 8693 exchange, see Standard token exchange. For an exchange that crosses a trust boundary between two authorization servers, see Cross App Access.
Some identity providers implement vendor-specific variants of this grant. Microsoft Entra on-behalf-of is covered later on this page.
Before you begin
Follow the Get started guide to install agentgateway.
Follow the Sample app guide to create a gateway proxy with an HTTP listener and deploy the httpbin sample app.
Get the external address of the gateway and save it in an environment variable.
Tip
Kind cluster? Kind does not support
LoadBalancerservices by default. To use this option with a Kind cluster, install and runcloud-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 aninitial-client(mints the user’s inbound token for the RFC 8693 grant), a confidentialrequester-client(the gateway’s client, with token exchange enabled), atarget-clientaudience, andtestuser/testpassuser credentials.idp: A separate identity provider realm that issues theassertionfor the RFC 7523 JWT bearer grant. Thebackend-oauthrealm trusts it through a JWT Authorization Grant identity provider.
Steps to deploy Keycloak:
Download the realm definitions and load them into a ConfigMap in the
httpbinnamespace, alongside the sample app. Thesedcommand rewrites the issuer host in the import (which is pinned tolocalhost:7080for 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.jsonDeploy Keycloak and its Service into the
httpbinnamespace. The--features=previewflag enables Keycloak’s JWT Authorization Grant, which the RFC 7523 JWT bearer grant requires. TheKC_HOSTNAMEvariable 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 EOFWait for Keycloak to be ready.
kubectl rollout status deployment/keycloak -n httpbin --timeout=180s
Configure token exchange
Configure agentgateway to exchange tokens.
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 EOFCreate a Kubernetes Secret with the gateway client’s secret. This matches the
requester-clientsecret from the imported realm.kubectl apply -f- <<EOF apiVersion: v1 kind: Secret metadata: name: oauth-client namespace: httpbin type: Opaque stringData: clientSecret: requester-secret EOFCreate an AgentgatewayPolicy that attaches the
oauthTokenExchangemethod to thehttpbinService, withgrantType: JwtBearer. Apart from the grant, the policy is the same as the one in the standard token exchange guide.Note
In this example, the assertion is a token from the
idprealm, which thebackend-oauthrealm trusts through its JWT Authorization Grant identity provider. Verify the exchange shows how to mint it.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: JwtBearer audiences: - target-client clientAuth: clientId: requester-client method: ClientSecretBasic secretRef: name: oauth-client EOFReview the following table to understand this configuration. For more information, see the API docs.
Field Description 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 backendRefto point at the authorization server directly, without creating an intermediate Kubernetes object. Mutually exclusive withbackendRef. Do not setpathwhen you useurl.pathPath of the token endpoint on the backend. Must start with /. Defaults to/.grantTypeTokenExchange(default, RFC 8693) orJwtBearer(RFC 7523).clientAuthClient authentication for the token endpoint. methodisClientSecretBasic(default),ClientSecretPost, orPrivateKeyJwt. UsesecretRefto read the client secret from a Kubernetes Secret.audiences,scopes,resourcesThe audience,scope, andresourceparameters sent to the token endpoint.resourcesare RFC 8707 resource indicators.subjectToken.sourceWhere the gateway reads the incoming token from. Set exactly one of header,queryParameter,cookie, orexpression, whereexpressionis a CEL expression that reads the token from the request, such as a claim of a validated JWT. Defaults to theAuthorizationheader with theBearerprefix.subjectToken.tokenTypeThe type that the gateway reports for that token. Use a built-in name such as AccessToken(the default),Jwt, orIdToken, or a custom absolute URI. See Token types.actorTokenOptional RFC 8693 delegation actor token ( TokenExchangegrant only). Takes the sametokenTypevalues assubjectToken.requestedTokenTypeOptional token type to request, limited to AccessToken,Jwt, orIdToken, and valid only with theTokenExchangegrant 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 Authorizationheader.additionalParamsExtra form parameters appended to the token request. Values are CEL expressions. cacheIn-memory token cache. Defaults to 8192 entries. Set inMemory.maxEntries: 0to disable.
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.
Port-forward the Keycloak Service so that you can reach its token endpoint locally.
kubectl port-forward -n httpbin svc/keycloak 8080:8080In another terminal, mint the incoming token. Mint a token from the
idprealm asidp-app; the gateway presents this as the RFC 7523assertionto thebackend-oauthrealm, which trusts theidprealm. Tokens expire, so re-mint if you come back later.export INBOUND_TOKEN="$(curl -s http://localhost:8080/realms/idp/protocol/openid-connect/token \ -u idp-app:idp-secret -d grant_type=password \ -d username=idpuser -d password=idppass | jq -r .access_token)" echo $INBOUND_TOKENSend a request to the httpbin
/headersendpoint 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
Authorizationheader reflected by httpbin contains a different token than the one you sent.Extract the exchanged token from the reflected
Authorizationheader 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 exchanged token is issued by
backend-oauth, not by theidprealm that issued the assertion, and its authorized party (azp) is the gateway’s client (requester-client). Thesubclaim isidpuserasbackend-oauthknows them, so it differs from the subject in the assertion you sent.{ "iss": "http://keycloak.httpbin.svc.cluster.local:8080/realms/backend-oauth", "aud": "target-client", "azp": "requester-client", "sub": "6aabea9b-d35a-469c-9fd7-1c5dc2073eff" }
Microsoft Entra on-behalf-of
The Microsoft Entra on-behalf-of (OBO) flow is a vendor-specific variant of the JWT bearer grant. Point the token endpoint at your Entra tenant, use ClientSecretPost client authentication, and add the requested_token_use=on_behalf_of parameter through additionalParams. Values in additionalParams are CEL expressions, so the literal string is quoted. Make sure to include your Entra <TENANT_ID> and <CLIENT_ID> values.
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: entra-token-endpoint
path: /<TENANT_ID>/oauth2/v2.0/token
grantType: JwtBearer
clientAuth:
clientId: <CLIENT_ID>
method: ClientSecretPost
secretRef:
name: oauth-client
scopes:
- https://graph.microsoft.com/.default
additionalParams:
requested_token_use: '"on_behalf_of"'
EOFTo verify this variant, mint a user access token from your Entra tenant as the incoming token, send it through the gateway, and inspect the exchanged on-behalf-of token that the gateway forwards, as in Verify the exchange.
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
443for 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 a route-level
jwtAuthenticationpolicy so that an invalid token is rejected before the exchange runs, as described in Validate the incoming token at the edge. That policy must setpreserveToken: true, or the exchange finds nosubject_token. - 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 -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