For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Token exchange for MCP servers
Verified Code examples on this page have been automatically tested and verified.Exchange the caller’s credential for a backend-scoped token before the gateway forwards a request to an MCP server.
Exchange the incoming token for a backend-scoped token before the gateway forwards a request to an MCP server.
About
MCP servers are a common target for token exchange. The client that calls the gateway authenticates as a user or an agent, but the MCP server behind the gateway expects a token that is scoped to itself, issued by an authorization server that the server trusts. Token exchange lets the gateway make that swap, so the MCP server never sees the incoming token, and the caller never holds a credential for the MCP server.
The configuration is the same oauthTokenExchange backend authentication method that the standard token exchange guide covers. What differs is the target: the policy attaches to an MCP AgentgatewayBackend rather than to a plain Service.
This guide uses an echo MCP server. Its echo tool returns the input that you send it, and with includeHttpHeaders=true it also returns the HTTP headers that it received, which makes the exchanged token directly observable in the tool response.
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
Deploy the MCP server
Deploy a sample echo MCP server and expose it through the gateway.
The MCP server goes in the same httpbin namespace as the Keycloak deployment from the previous section, so that the token endpoint backend and the exchange policy can reference each other without a ReferenceGrant.
Deploy the
echoMCP server.kubectl apply -f- <<EOF apiVersion: apps/v1 kind: Deployment metadata: name: echo namespace: httpbin labels: app: echo spec: replicas: 1 selector: matchLabels: app: echo template: metadata: labels: app: echo spec: containers: - name: echo image: gcr.io/product-excellence-424719/mcp-echo:1.0 imagePullPolicy: IfNotPresent args: ["--oauth-enabled", "false"] ports: - containerPort: 3002 readinessProbe: httpGet: { path: /healthz, port: 3002 } initialDelaySeconds: 10 --- apiVersion: v1 kind: Service metadata: name: echo namespace: httpbin labels: app: echo spec: selector: app: echo ports: - port: 3002 targetPort: 3002 appProtocol: agentgateway.dev/mcp EOFCreate an AgentgatewayBackend that targets the
echoserver. This backend sets no backend-level authentication, so the policy that you apply later is the only place that token exchange happens.kubectl apply -f- <<EOF apiVersion: agentgateway.dev/v1alpha1 kind: AgentgatewayBackend metadata: name: mcp-backend-echo namespace: httpbin spec: mcp: targets: - name: echo-target selector: services: matchLabels: app: echo EOFCreate an
HTTPRoutethat exposes the MCP backend on the/mcppath of your gateway.kubectl apply -f- <<EOF apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: mcp-echo namespace: httpbin spec: parentRefs: - name: agentgateway-proxy namespace: agentgateway-system rules: - matches: - path: type: PathPrefix value: /mcp backendRefs: - name: mcp-backend-echo group: agentgateway.dev kind: AgentgatewayBackend EOFVerify that the route is accepted.
kubectl -n httpbin get httproute mcp-echo -o jsonpath='{.status.parents[*].conditions[*].type}={.status.parents[*].conditions[*].status}{"\n"}'Example output:
Accepted ResolvedRefs=True True
Configure token exchange
Configure agentgateway to exchange the incoming token before it reaches the MCP server.
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 the MCP AgentgatewayBackend. Unlike the Service-targeted policies in the other guides,targetRefsnames the backend.kubectl apply -f- <<EOF apiVersion: agentgateway.dev/v1alpha1 kind: AgentgatewayPolicy metadata: name: mcp-token-exchange namespace: httpbin spec: targetRefs: - group: agentgateway.dev kind: AgentgatewayBackend name: mcp-backend-echo 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 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
Call the echo tool through the gateway and confirm that the Authorization header the MCP server received carries the exchanged token, not the one you sent.
Port-forward the Keycloak Service and the gateway proxy.
kubectl port-forward -n httpbin svc/keycloak 8080:8080 & kubectl port-forward -n agentgateway-system svc/agentgateway-proxy 8888:80 &Mint the incoming token as
initial-client. The gateway sends this as thesubject_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_TOKENCall the
echotool withincludeHttpHeaders=true, so that the tool returns the HTTP headers that the MCP server received.npx @modelcontextprotocol/[email protected] \ --cli http://localhost:8888/mcp \ --transport http \ --method tools/call \ --tool-name echo \ --tool-arg input=test \ --tool-arg includeHttpHeaders=true \ --header "Authorization: Bearer $INBOUND_TOKEN"The second content item of the response is the request that reached the MCP server. Note that its
authorizationheader carries a different token than the one you sent.{ "method": "POST", "url": "/mcp", "headers": { "mcp-session-id": "5cbdbd08-7f27-4adc-a51e-ef0d987f1166", "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI..." } }Copy the exchanged token from that
authorizationheader, and save it to an environment variable.export FORWARDED_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI...Decode both tokens to confirm the exchange.
for t in "$INBOUND_TOKEN" "$FORWARDED_TOKEN"; do echo "$t" | cut -d. -f2 | jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson | {iss, aud, azp}' doneThe inbound token was issued to
initial-clientfor therequester-clientaudience. The exchanged token was issued for thetarget-clientaudience, with the gateway’s own client (requester-client) as the authorized party.{ "iss": "http://keycloak.httpbin.svc.cluster.local:8080/realms/backend-oauth", "aud": "requester-client", "azp": "initial-client" } { "iss": "http://keycloak.httpbin.svc.cluster.local:8080/realms/backend-oauth", "aud": "target-client", "azp": "requester-client" }
Next steps
- Validate the incoming token at the edge. The exchange forwards the incoming token to the authorization server as received, without validating it first. Pair the policy with a route-level JWT authentication or MCP authentication policy so that invalid tokens are rejected before any call to the token endpoint. Set
preserveToken: trueon it, or the exchange finds nosubject_token; for a worked example, see Validate the incoming token at the edge. - Scope the exchanged token per MCP server. Attach a separate policy to each MCP AgentgatewayBackend, each with its own
audiences, so every server receives a token that is valid only for itself. - Restrict which tools each caller may reach. Token exchange decides which token the gateway sends, not who is allowed through. Add an MCP authorization policy alongside it.
Cleanup
Stop the port-forwards that you started in Verify the exchange.
kill %1 %2Then delete the resources.
kubectl delete AgentgatewayPolicy mcp-token-exchange -n httpbin
kubectl delete AgentgatewayBackend mcp-backend-echo keycloak-token-endpoint -n httpbin
kubectl delete httproute mcp-echo -n httpbin
kubectl delete secret oauth-client -n httpbin
kubectl delete deployment echo keycloak -n httpbin
kubectl delete service echo keycloak -n httpbin
kubectl delete configmap backend-oauth-realm -n httpbin