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.

Token exchange for MCP servers

Verified Code examples on this page have been automatically tested and verified.
Page as Markdown

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

  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

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.

  1. Deploy the echo MCP 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
    EOF
  2. Create an AgentgatewayBackend that targets the echo server. 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
    EOF
  3. Create an HTTPRoute that exposes the MCP backend on the /mcp path 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
    EOF
  4. Verify 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.

  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 MCP AgentgatewayBackend. Unlike the Service-targeted policies in the other guides, targetRefs names 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
    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.

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.

  1. 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 &
  2. Mint the incoming token 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. Call the echo tool with includeHttpHeaders=true, so that the tool returns the HTTP headers that the MCP server received.

    npx @modelcontextprotocol/inspector@0.21.2 \
      --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 authorization header 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..."
      }
    }
  4. Copy the exchanged token from that authorization header, and save it to an environment variable.

    export FORWARDED_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUI...
  5. 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}'
    done

    The inbound token was issued to initial-client for the requester-client audience. The exchanged token was issued for the target-client audience, 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: true on it, or the exchange finds no subject_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 %2

Then 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
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/.