MastertheMesh
Setup guide · Token Exchange · Agentgateway · MCP

RFC 8693 token exchange across identity providers

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

How to wire up OAuth 2.0 token exchange on Solo Enterprise Agentgateway for the five identity providers you'll meet most: Microsoft Entra ID, Keycloak, Auth0, Okta and Frontegg. Each section has copy-paste YAML, every file explained, and the per-IdP issuer, JWKS and grant-type differences called out. Sample gateway host throughout: agentgateway.acme.io. YAML is pinned to the current GA line (chart v2026.6.0, docs latest).

RFC 8693 Token Exchange Microsoft Entra ID Keycloak Auth0 Okta Frontegg EnterpriseAgentgatewayPolicy

Token exchange is the step where the gateway stops carrying the user's login token and starts carrying a token the downstream will accept — scoped, audience-correct, and still tied to the real user. The mechanics are identical across identity providers; only a handful of URLs and one grant-type choice change. This page walks all five, file by file.

What token exchange actually does

An MCP request arrives at agentgateway.acme.io carrying the caller's corporate JWT — minted by your IdP when the user logged in. That token is scoped for your gateway, not for whatever sits behind it. Forwarding it verbatim to a downstream API would fail audience validation (best case) or over-grant (worst case). Token exchange fixes that: the gateway presents the inbound user token to the IdP and asks for a new token, scoped for the downstream, still carrying the user's identity in sub. That is RFC 8693, OAuth 2.0 Token Exchange.

There are two legs, and it's worth keeping them straight because the per-IdP differences split cleanly across them. The first thing to unlearn: validation and exchange are not a single policy block. The exchange server (the STS, the same thing that performs the swap) is what validates the inbound token, and you configure those validators once at install time, not per request.

LegWhat it doesWhere it's configuredWhat changes per IdP
Inbound
(validate)
The exchange server validates the user's JWT (signature, issuer) before it will exchange anything. Install-time Helm: tokenExchange.subjectValidator / actorValidator / apiValidator. The JWKS URL on subjectValidator and apiValidator (a remote validator pointed at the IdP's keys).
Upstream
(the exchange)
Swaps the validated user token for a downstream-scoped token via RFC 8693 (or Entra's OBO grant). EnterpriseAgentgatewayPolicyspec.backend.tokenExchange, attached to your EnterpriseAgentgatewayBackend, plus a credential Secret. The grant (generic elicitation vs Entra's entra block) and the IdP token URLs.

So the per-IdP work is small: point the install-time validators at the IdP's JWKS, then attach one exchange policy to the backend. A separate, optional layer (route-level traffic.jwtAuthentication with an mcp extension) handles MCP OAuth discovery for clients; it's covered at the end, not repeated per IdP.

What's fixed, what's per-IdP

Before the YAML, the single most useful thing to internalise: almost nothing about the shape changes between providers. The CRD kinds, the field names, the discovery handshake, and the gateway acting as the authorization server are all standardised. Four small things flip.

Stays the same for every IdPChanges per IdP
The CRD kinds (EnterpriseAgentgatewayPolicy, EnterpriseAgentgatewayBackend, AgentgatewayBackend, Secret). The JWKS URL on the install-time subjectValidator and apiValidator.
The tokenExchange Helm shape (issuer, tokenExpiration, the three validators). The JWKS host and path, supplied to the validator and to the JWKS AgentgatewayBackend.
The spec.backend.tokenExchange structure and its attachment to the EnterpriseAgentgatewayBackend. The audiences convention.
The JWKS AgentgatewayBackend with policies.tls for any IdP reached on 443. The grant: generic RFC 8693 (elicitation) vs Entra's jwt-bearer OBO (entra).

Four of the five (Keycloak, Auth0, Okta, Frontegg) use the generic RFC 8693 grant via tokenExchange.elicitation. Entra is the exception: it uses Microsoft's own on-behalf-of grant (urn:ietf:params:oauth:grant-type:jwt-bearer) and gets a dedicated tokenExchange.entra block with mode: ExchangeOnly. Same outcome, different grant, and the article calls it out at each step.

Anatomy of a setup — the files you write

Per provider you write the same small set of objects. Learn them once and the per-IdP sections become "fill in these values":

  1. The tokenExchange Helm values (install time) — turn on the exchange server and set its three validators. The subjectValidator and apiValidator are remote validators pointed at the IdP's JWKS. This is the inbound-validation leg.
  2. A JWKS AgentgatewayBackend — a static backend pointing at the IdP host on port 443, with policies.tls: {} so the gateway can dial it over HTTPS to fetch keys. Skip the TLS block and the fetch fails.
  3. A credential Secret — the OAuth client the exchange server uses when it calls the IdP's token endpoint. (Entra is the exception: its client lives inline on the policy, with only the secret value in a Secret.)
  4. An EnterpriseAgentgatewayBackend plus its HTTPRoute — the MCP server the policy targets. Unchanged from any other agentgateway MCP setup, so it's shown once here and not repeated per IdP.
  5. An EnterpriseAgentgatewayPolicyspec.backend.tokenExchange performs the swap, and spec.targetRefs attaches it to the EnterpriseAgentgatewayBackend above.
EnterpriseAgentgatewayBackend + HTTPRoute · the MCP server the policy targetsidentical for every IdP
# The MCP server (here a static StreamableHTTP target) and the route that
# exposes it. The token-exchange policy in each section targets THIS backend.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata:
  name: acme-mcp-backend
  namespace: agentgateway-system
spec:
  mcp:
    targets:
    - name: acme-tool
      static:
        host: acme-tool.agentgateway-system.svc.cluster.local
        port: 8080
        path: /mcp
        protocol: StreamableHTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: acme-mcp
  namespace: agentgateway-system
spec:
  parentRefs:
  - name: agentgateway-proxy
  rules:
  - matches:
    - path: { type: PathPrefix, value: /acme/mcp }
    backendRefs:
    - name: acme-mcp-backend
      group: enterpriseagentgateway.solo.io
      kind: EnterpriseAgentgatewayBackend

What this file is

EnterpriseAgentgatewayBackend
The enterprise backend kind. Token exchange is an enterprise feature, and this is the backend the exchange policy attaches to. The MCP server defs sit under spec.mcp.targets.
/acme/mcp
The live MCP endpoint. JSON-RPC tool calls and the streaming transport.

If you also want MCP OAuth discovery (the .well-known metadata paths so a client can run the flow itself), that's the optional route-level layer in Discovery below. The exchange flow on its own does not need it.

Prerequisite: enable the exchange server and its validators

The token-exchange server is off by default, and turning it on is more than a single flag: this is also where inbound validation lives. The three validators decide whether the exchange server trusts the token it's being asked to swap. You set them at install time, in the tokenExchange Helm block. The per-IdP sections below only change the JWKS URL these validators point at.

tokenExchange Helm values · server + validatorsinstall-time, once per gateway
# enterprise-agentgateway Helm values (chart v2026.6.0). With this off,
# every backend.tokenExchange parses fine but does nothing — requests fall
# through with the original token intact.
tokenExchange:
  enabled: true                                       # start the exchange server
  issuer: "enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777"
  tokenExpiration: 24h
  oidc:
    secretName: acme-default-oauth                    # cluster-wide default OAuth Secret
  subjectValidator:                                   # validates the inbound user token
    validatorType: remote
    remoteConfig:
      url: "https://keycloak.acme.io/realms/acme/protocol/openid-connect/certs"
  actorValidator:                                     # validates the gateway's own SA token
    validatorType: k8s
  apiValidator:                                       # guards the exchange/elicitation API
    validatorType: remote
    remoteConfig:
      url: "https://keycloak.acme.io/realms/acme/protocol/openid-connect/certs"

The fields that matter

enabled: true
Starts the exchange server inside the enterprise-agentgateway pod. Required, or every policy is inert.
issuer
The exchange server's own identifier (the in-cluster service on port 7777). Required.
subjectValidator / apiValidator
The two you change per IdP. Set validatorType: remote with remoteConfig.url pointed at the IdP's JWKS. This is what validates the inbound user JWT.
actorValidator
Validates the gateway's own ServiceAccount token. k8s in-cluster; leave it as is.
oidc.secretName
A cluster-wide default OAuth Secret. A policy that sets its own tokenExchange.elicitation.secretName overrides it per backend.

Leave apiValidator out and the controller fails to start. tokenExpiration is optional (defaults to one hour). The exchange server also exposes an issuer-proxy on :7777/oauth-issuer, used by the discovery layer at the end.

Microsoft Entra ID

Entra ID jwt-bearer OBO · the native exception

Entra doesn't use the generic RFC 8693 grant. It uses Microsoft's on-behalf-of flow (jwt-bearer), and agentgateway gives it a first-class tokenExchange.entra block so you wire the tenant and client directly rather than through an opaque Secret. Inbound validation is done by the install-time validators (pointed at Entra's keys), and the exchange policy below carries mode: ExchangeOnly.

In the Entra portal, before you apply YAML:
  1. Register an app for the gateway. Note its Application (client) ID and Directory (tenant) ID — both UUIDs.
  2. Under Expose an API, set the Application ID URI to api://<app-id> and add a scope (e.g. agentgateway).
  3. Under API permissions, add the downstream resource you'll exchange for (e.g. Microsoft Graph .default).
  4. Create a client secret — this is the only value that lands in a Kubernetes Secret.
AgentgatewayBackend · JWKS resolution for Entrastatic host → login.microsoftonline.com
# A static backend the gateway dials to fetch Entra's public signing keys,
# the egress path for the install-time validators reaching Entra on 443.
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: entra-jwks
  namespace: agentgateway-system
spec:
  static:
    host: login.microsoftonline.com    # where Entra publishes its JWKS
    port: 443
  policies:
    tls: {}                            # HTTPS egress to the IdP; omit and the key fetch fails

What this file is

spec.static.host
The IdP hostname the gateway connects to for key material. For Entra, JWKS lives on login.microsoftonline.com.
policies.tls: {}
Required for any IdP reached on 443. It tells the gateway to dial the host over HTTPS. Leave it off and the JWKS fetch fails TLS. (In-cluster IdPs reached over plain HTTP on 8080 don't need it.)

Exactly one backend type may be set on an AgentgatewayBackend; here it's static. The MCP server the policy targets is the separate EnterpriseAgentgatewayBackend above.

Secret · the Entra client secret valueonly the secret — the rest is inline on the policy
# For Entra, the client_id / tenant_id / scope live ON the policy (next
# file). Only the opaque secret value goes here.
apiVersion: v1
kind: Secret
metadata:
  name: entra-gateway-secret
  namespace: agentgateway-system
type: Opaque
stringData:
  client_secret: "<entra-app-client-secret>"

Why Entra's Secret is so small

client_secret
The only sensitive value. The entra block references it by name + key, so the key name here (client_secret) must match clientSecretRef.key below.

Contrast this with the elicitation Secret used by the other three IdPs, which also carries client_id, authorize_url, access_token_url and scopes.

Entra tokenExchange validators · install-time Helmhow Entra validates inbound
# For Entra OBO, the inbound validators in the tokenExchange Helm block
# point at Entra's discovery keys, and elicitation is disabled.
tokenExchange:
  enabled: true
  issuer: "enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777"
  tokenExpiration: 24h
  subjectValidator:
    validatorType: remote
    remoteConfig:
      url: "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/discovery/v2.0/keys"
  apiValidator:
    validatorType: remote
    remoteConfig:
      url: "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/discovery/v2.0/keys"
  actorValidator:
    validatorType: k8s
  elicitation:
    secretName: ""            # elicitation is not used in the Entra OBO flow

The Entra-specific install values

subjectValidator / apiValidator
Both remote, both pointed at the tenant's /discovery/v2.0/keys. This is where the inbound user token is validated for Entra.
elicitation.secretName: ""
Empty string disables elicitation. The OBO flow has nothing to elicit, so leave it empty.

Note the v1.0 issuer gotcha still applies to your tokens: Entra access tokens carry iss = https://sts.windows.net/<tenant>/, not the v2.0 login.microsoftonline.com form. That's a property of the token you mint, not a field you set here.

EnterpriseAgentgatewayPolicy · the OBO exchange (Entra)just the exchange leg
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: entra-obo-policy
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend            # the MCP backend, not the JWKS backend
  backend:
    tokenExchange:
      mode: ExchangeOnly              # OBO exchanges without eliciting
      entra:
        tenantId: 11111111-2222-3333-4444-555555555555
        clientId: a1b2c3d4-e5f6-7890-abcd-ef1234567890
        scope: https://graph.microsoft.com/.default
        clientSecretRef:
          name: entra-gateway-secret
          key: client_secret

Field by field — the Entra specifics

mode: ExchangeOnly
Without this, the default tries elicitation as well as exchange. The OBO flow is exchange-only, so set it explicitly.
targetRefsEnterpriseAgentgatewayBackend
The exchange policy attaches to the enterprise MCP backend you defined above.
tokenExchange.entra
The native OBO block. tenantId and clientId are UUIDs; scope is the downstream resource (here Graph). The gateway sends grant urn:ietf:params:oauth:grant-type:jwt-bearer.

You may set tokenExchange.entra or tokenExchange.elicitation on a policy, never both: a CEL rule on the CRD rejects it. Entra always uses entra.

Keycloak

Keycloak generic RFC 8693 · realm-scoped URLs

Keycloak is the textbook RFC 8693 case: install-time validators pointed at the realm certs, and a credential Secret pointing at the realm's token endpoint. Everything hangs off the realm path /realms/<realm>. Here the realm is acme on keycloak.acme.io.

In the Keycloak admin console, before you apply YAML:
  1. Enable the token-exchange feature on the server (start flag --features=token-exchange, or the v2 standard token-exchange toggle).
  2. Create a confidential client for the gateway; note its Client ID and Client secret.
  3. Add an Audience protocol mapper so issued tokens carry the right aud — Keycloak omits it otherwise, and validation fails.
  4. Grant the client permission to exchange tokens for the downstream client/audience.
AgentgatewayBackend · JWKS resolution for Keycloakstatic host → keycloak.acme.io
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: keycloak-jwks
  namespace: agentgateway-system
spec:
  static:
    host: keycloak.acme.io
    port: 443
  policies:
    tls: {}            # external Keycloak on 443; drop it for an in-cluster Keycloak on 8080

What this file is

spec.static.host
Your Keycloak hostname. The realm certs path is what you feed the install-time validator's remoteConfig.url.
policies.tls: {}
Needed here because this Keycloak is reached over HTTPS on 443. An in-cluster Keycloak on plain HTTP (8080) omits the block.

Same role as the Entra backend, only the host changes.

Secret · Keycloak exchange credentialsfull elicitation Secret — URLs + client + scopes
# The OAuth elicitation Secret: type, client credentials, the realm's OAuth
# endpoints, and the UI callback. The exchange server reads this to call Keycloak.
apiVersion: v1
kind: Secret
metadata:
  name: keycloak-token-exchange
  namespace: agentgateway-system
type: Opaque
stringData:
  type: oauth
  app_id: keycloak
  client_id: "agentgateway-exchange"
  client_secret: "<keycloak-client-secret>"
  authorize_url: "https://keycloak.acme.io/realms/acme/protocol/openid-connect/auth"
  access_token_url: "https://keycloak.acme.io/realms/acme/protocol/openid-connect/token"
  scopes: "openid mcp:invoke offline_access"
  redirect_uri: "https://agentgateway.acme.io/age/elicitations"

The OAuth Secret keys

type: oauth
Marks this as an OAuth credential Secret. Leave it out and the Secret isn't recognised.
client_id / client_secret
The confidential client you created in Keycloak. These authenticate the gateway when it calls the token endpoint.
authorize_url / access_token_url
The realm's OAuth endpoints. access_token_url is where the RFC 8693 exchange POST lands; authorize_url is used when consent must be elicited first.
redirect_uri
The Solo Enterprise UI callback, on the path /age/elicitations. Used by the elicitation consent leg.

This same shape appears for Auth0 and Okta, only the URLs change. Entra is the one that doesn't use it.

Inbound validation comes from the install-time validators: set subjectValidator and apiValidator to remote with remoteConfig.url = https://keycloak.acme.io/realms/acme/protocol/openid-connect/certs. The realm's iss (https://keycloak.acme.io/realms/acme, no trailing slash) and the aud from your Audience mapper are what those keys verify. The policy itself is just the exchange:

EnterpriseAgentgatewayPolicy · the exchange (Keycloak)generic RFC 8693
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: keycloak-exchange-policy
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend
  backend:
    tokenExchange:
      elicitation:
        secretName: keycloak-token-exchange

Field by field — the Keycloak specifics

targetRefsEnterpriseAgentgatewayBackend
The exchange policy attaches to the enterprise MCP backend, not to the JWKS backend.
tokenExchange.elicitation.secretName
Points at the OAuth Secret above. The gateway sends the standard token-exchange grant to Keycloak's token endpoint. This per-policy Secret overrides the cluster-wide oidc.secretName default.

Keycloak gotcha lives in the realm, not the policy: add an Audience protocol mapper, or issued tokens carry no aud and the validator rejects them.

Auth0

Auth0 generic RFC 8693 · audience must be requested

Auth0 follows the same elicitation pattern as Keycloak. Its one quirk: Auth0 only stamps an aud claim when the client explicitly requests an API Identifier as the audience. Tenant host here is acme.eu.auth0.com.

In the Auth0 dashboard, before you apply YAML:
  1. Create an API; its Identifier (e.g. https://acme-api/agentgateway) becomes your audience.
  2. Create a Machine-to-Machine application for the gateway, authorized for that API; note its Client ID and Client Secret.
  3. Define the scopes the downstream needs on the API (e.g. mcp:invoke).
AgentgatewayBackend · JWKS resolution for Auth0static host → acme.eu.auth0.com
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: auth0-jwks
  namespace: agentgateway-system
spec:
  static:
    host: acme.eu.auth0.com
    port: 443
  policies:
    tls: {}            # Auth0 is always reached over HTTPS on 443

What this file is

spec.static.host
Your Auth0 tenant domain. Auth0 serves its keys at the fixed path /.well-known/jwks.json, which you append to the host for the validator URL.
policies.tls: {}
Auth0 is a hosted IdP on 443, so the TLS block is required.
Secret · Auth0 exchange credentialssame six keys, Auth0 URLs
apiVersion: v1
kind: Secret
metadata:
  name: auth0-token-exchange
  namespace: agentgateway-system
type: Opaque
stringData:
  type: oauth
  app_id: auth0
  client_id: "<auth0-m2m-client-id>"
  client_secret: "<auth0-m2m-client-secret>"
  authorize_url: "https://acme.eu.auth0.com/authorize"
  access_token_url: "https://acme.eu.auth0.com/oauth/token"
  scopes: "openid mcp:invoke offline_access"
  redirect_uri: "https://agentgateway.acme.io/age/elicitations"

The Auth0 endpoints

access_token_url
/oauth/token, Auth0's single token endpoint, where the exchange POST lands.
authorize_url
/authorize, used during the consent/elicitation leg.
scopes
Must align with scopes you defined on the Auth0 API, or Auth0 silently drops the unknown ones.

Same type: oauth + redirect_uri shape as Keycloak. The exchange request must also send the API Identifier as audience, which is how Auth0 knows to issue a token for your downstream rather than its own /userinfo.

Point the install-time subjectValidator and apiValidator at https://acme.eu.auth0.com/.well-known/jwks.json. The tokens those keys verify carry Auth0's issuer with a trailing slash (https://acme.eu.auth0.com/) and the API Identifier as aud. The policy is just the exchange:

EnterpriseAgentgatewayPolicy · the exchange (Auth0)generic RFC 8693
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: auth0-exchange-policy
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend
  backend:
    tokenExchange:
      elicitation:
        secretName: auth0-token-exchange

The Auth0 specifics (in the validator URL and tokens)

issuer trailing slash
Auth0's issuer has a trailing slash (https://<tenant>/), unlike Keycloak. The validator matches the token's iss exactly.
audience = API Identifier
The API Identifier string you set in the dashboard, a URI, not necessarily a real URL. This is the value the M2M app requests and the token's aud.
JWKS path
Auth0 always serves keys at /.well-known/jwks.json, appended to the host for the validator URL.

Okta

Okta generic RFC 8693 · use a custom authorization server

Okta works with the standard RFC 8693 grant, but with one structural rule: you must use a custom authorization server (the /oauth2/<authServerId> path), not the org-level one. The org server can't issue access tokens for your own APIs. Org host here is acme.okta.com; the custom auth server id is aus1a2b3c4D5e6F7g8.

In the Okta admin console, before you apply YAML:
  1. Under Security → API, create (or use) a custom Authorization Server; note its Audience (e.g. api://acme) and its ID.
  2. Create an OIDC app for the gateway; note its Client ID and Client Secret.
  3. Enable the Token Exchange grant type on that app.
  4. Add scopes and an access policy on the custom auth server that permits the exchange.
AgentgatewayBackend · JWKS resolution for Oktastatic host → acme.okta.com
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: okta-jwks
  namespace: agentgateway-system
spec:
  static:
    host: acme.okta.com
    port: 443
  policies:
    tls: {}            # Okta is reached over HTTPS on 443

What this file is

spec.static.host
Your Okta org host. The custom-auth-server keys path (/oauth2/<id>/v1/keys) is appended to it for the validator URL.
policies.tls: {}
Required, Okta is a hosted IdP on 443.

If you use an Okta custom domain (e.g. login.acme.com), set that as the host instead, and match it in the token's iss.

Secret · Okta exchange credentialscustom-auth-server URLs
apiVersion: v1
kind: Secret
metadata:
  name: okta-token-exchange
  namespace: agentgateway-system
type: Opaque
stringData:
  type: oauth
  app_id: okta
  client_id: "<okta-app-client-id>"
  client_secret: "<okta-app-client-secret>"
  authorize_url: "https://acme.okta.com/oauth2/aus1a2b3c4D5e6F7g8/v1/authorize"
  access_token_url: "https://acme.okta.com/oauth2/aus1a2b3c4D5e6F7g8/v1/token"
  scopes: "openid mcp:invoke offline_access"
  redirect_uri: "https://agentgateway.acme.io/age/elicitations"

The Okta endpoints

access_token_url
Note the /oauth2/<authServerId>/v1/token shape — the custom server, not /oauth2/v1/token (the org server, which won't work).
authorize_url
The matching /v1/authorize on the same custom server.

Getting the org server vs custom server wrong is the single most common Okta mistake — the symptom is an opaque token Okta won't let you introspect against your API.

Point the install-time subjectValidator and apiValidator at the custom server's keys, https://acme.okta.com/oauth2/aus1a2b3c4D5e6F7g8/v1/keys. Tokens from that server carry iss = https://acme.okta.com/oauth2/aus1a2b3c4D5e6F7g8 and the custom server's Audience. The policy is just the exchange:

EnterpriseAgentgatewayPolicy · the exchange (Okta)generic RFC 8693
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: okta-exchange-policy
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend
  backend:
    tokenExchange:
      elicitation:
        secretName: okta-token-exchange

The Okta specifics (in the validator URL and tokens)

custom-server issuer
https://<org>/oauth2/<authServerId>. The org-level issuer (/oauth2/v1/…) won't match tokens from the custom server, which is the single most common Okta mistake.
JWKS path
/oauth2/<authServerId>/v1/keys, again the custom server's path.
audience
The Audience configured on the custom auth server (e.g. api://acme).

The exchange block is identical to Keycloak and Auth0; only the URLs in the validator and the Secret change.

Frontegg

Frontegg generic RFC 8693 · hosted env subdomain

Frontegg follows the same elicitation pattern as Keycloak, Auth0 and Okta — it's a hosted OIDC provider whose token endpoint advertises the RFC 8693 token-exchange grant. Its one quirk is the host: each Frontegg environment lives on its own generated subdomain (the portal calls it the Frontegg domain), so confirm the exact issuer and JWKS URL from the environment's OIDC endpoints panel rather than assuming. Sample host here is acme.frontegg.com.

In the Frontegg portal, before you apply YAML:
  1. Note your environment's Frontegg domain under Authentication → SSO → Identity Provider → OpenID Connect Endpoints — that's the host for issuer and JWKS.
  2. Create (or use) an application; note its Client ID and Secret.
  3. Set the aud your tokens should carry via Token management → JWT claims (Frontegg's aud accepts the clientId or appId).
  4. Create the tenant and users you'll exchange tokens for.
AgentgatewayBackend · JWKS resolution for Fronteggstatic host → acme.frontegg.com
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: frontegg-jwks
  namespace: agentgateway-system
spec:
  static:
    host: acme.frontegg.com     # your environment's Frontegg domain
    port: 443
  policies:
    tls: {}            # Frontegg is a hosted IdP on 443

What this file is

spec.static.host
Your environment's Frontegg domain. Frontegg serves keys at the fixed path /.well-known/jwks.json, which you append to the host for the validator URL.
policies.tls: {}
Required, Frontegg is a hosted IdP on 443.

Each Frontegg environment has its own subdomain (a generated one like app-xxxxxxxx.frontegg.com, or a custom domain you've mapped); set whichever the OIDC endpoints panel shows and match it in the token's iss.

Secret · Frontegg exchange credentialsFrontegg OAuth URLs
apiVersion: v1
kind: Secret
metadata:
  name: frontegg-token-exchange
  namespace: agentgateway-system
type: Opaque
stringData:
  type: oauth
  app_id: frontegg
  client_id: "<frontegg-client-id>"
  client_secret: "<frontegg-client-secret>"
  authorize_url: "https://acme.frontegg.com/oauth/authorize"
  access_token_url: "https://acme.frontegg.com/oauth/token"
  scopes: "openid mcp:invoke offline_access"
  redirect_uri: "https://agentgateway.acme.io/age/elicitations"

The Frontegg endpoints

access_token_url
/oauth/token on the Frontegg domain — its hosted-login OAuth token endpoint, which advertises the token-exchange grant in its discovery document.
authorize_url
/oauth/authorize, used during the consent/elicitation leg.

Same type: oauth + redirect_uri shape as Keycloak, Auth0 and Okta; only the host changes.

Point the install-time subjectValidator and apiValidator at https://acme.frontegg.com/.well-known/jwks.json. Tokens carry iss = https://acme.frontegg.com and the aud from your JWT-claims config. The policy is just the exchange:

EnterpriseAgentgatewayPolicy · the exchange (Frontegg)generic RFC 8693
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: frontegg-exchange-policy
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend
  backend:
    tokenExchange:
      elicitation:
        secretName: frontegg-token-exchange

The Frontegg specifics (in the validator URL and tokens)

issuer
https://<your-env>.frontegg.com — the environment's Frontegg domain, no path suffix.
JWKS path
/.well-known/jwks.json, the fixed Frontegg keys path.
audiences
The clientId or appId you set in the JWT-claims config.

The exchange block is identical to Keycloak, Auth0 and Okta; only the host in the validator and the Secret change.

Optional: MCP OAuth discovery for clients

Everything above is the exchange. There's a separate, optional layer: letting an MCP client discover how to authenticate itself, so it can run the OAuth flow and arrive with a valid token in the first place. To the client, the gateway presents itself as the authorization server. This is configured at the route level with traffic.jwtAuthentication and an mcp extension, and it's the same for every IdP, so it's shown once here.

EnterpriseAgentgatewayPolicy · route-level MCP auth + discoverytargets the HTTPRoute
# Attach to the HTTPRoute (not the backend). Validates inbound JWTs at the
# route and serves the .well-known discovery metadata for MCP clients.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: acme-mcp-discovery
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: acme-mcp
  traffic:
    jwtAuthentication:
      mode: Strict
      providers:
      - issuer: https://keycloak.acme.io/realms/acme
        audiences:
        - agentgateway
        jwks:
          remote:
            backendRef:
              name: keycloak-jwks
              group: agentgateway.dev
              kind: AgentgatewayBackend
            jwksPath: /realms/acme/protocol/openid-connect/certs
      mcp:
        provider: Keycloak
        resourceMetadata:
          resource: https://agentgateway.acme.io/acme/mcp
          scopesSupported:
          - mcp:invoke
          bearerMethodsSupported:
          - header
          - body
          - query

What this adds, and the two shapes of resourceMetadata

traffic.jwtAuthentication
Route-level JWT validation. Placing it here (not on the backend) runs auth before transformation and rate limiting, and lets other route policies use the JWT claims.
mcp.resourceMetadata
What the gateway serves at the .well-known paths. The basic shape is resource + scopesSupported + bearerMethodsSupported, shown here.
the issuer-proxy shape
When the gateway proxies the IdP as the authorization server, resourceMetadata instead carries agentgateway.dev/issuer-proxy (pointing at :7777/oauth-issuer) alongside authorizationServers and resource. Both are valid; which you use depends on whether you run the issuer-proxy flow.

For discovery to work, the HTTPRoute also needs the two .well-known match paths (/.well-known/oauth-protected-resource/… and /.well-known/oauth-authorization-server/…) added alongside the MCP path.

What the exchange looks like on the wire

You never write this request yourself — it's what the gateway's exchange server POSTs to access_token_url (or, for Entra, the tenant token endpoint). Seeing it makes the grant-type difference concrete:

POST /token · the RFC 8693 exchange the gateway performsgeneric grant — Keycloak / Auth0 / Okta
# Generic RFC 8693 — what the gateway sends for Keycloak, Auth0 and Okta:
POST /oauth/token  (or the realm/custom-server token endpoint)
  grant_type           = urn:ietf:params:oauth:grant-type:token-exchange
  subject_token        = <the validated inbound user JWT>
  subject_token_type   = urn:ietf:params:oauth:token-type:jwt
  requested_token_type = urn:ietf:params:oauth:token-type:access_token
  audience             = <the downstream resource>
  scope                = mcp:invoke offline_access
  client_id / client_secret = <from the elicitation Secret>

# Entra is different — Microsoft's on-behalf-of grant:
POST /<tenant>/oauth2/v2.0/token
  grant_type           = urn:ietf:params:oauth:grant-type:jwt-bearer
  assertion            = <the validated inbound user JWT>
  requested_token_use  = on_behalf_of
  scope                = https://graph.microsoft.com/.default
  client_id / client_secret = <from tokenExchange.entra>

The one line that differs

grant_type
Generic providers get token-exchange; Entra gets jwt-bearer. That single choice is why Entra has its own entra block and the others share elicitation.
subject_token / assertion
Both carry the same thing — the user's validated JWT. The gateway never invents identity; it always exchanges the real user's token.

The returned token has sub = the user. Whether it also carries an act claim (delegation) or not (impersonation) is decided by the IdP from the grant and audience — not by anything in your policy.

Scopes — the three layers

The most common point of confusion: scopesSupported in the policy is not where per-user permissions live. There are three independent layers, and only the bottom one varies by user:

LayerWhereGranularityPer-user?
1 · Reach the gateway aud verified by the install-time validators; scopesSupported advertised in discovery Coarse, usually one scope No, same for everyone
2 · Downstream ceiling scopes in the exchange Secret (or entra.scope) Coarse, admin sets the max No, global per backend
3 · Per-user / per-tool CEL on jwt.sub, claims, mcp.tool.name Fine Yes, this is the per-user layer

So you never enumerate every scope or hand-maintain per-user lists. The admin sets one coarse access scope (layer 1) and one ceiling (layer 2), once. Per-user variation comes from the IdP issuing each user a token scoped to their own entitlements at consent time, and from CEL authorization at the gateway:

authorization · per-user, per-tool CEL on the same policylayer 3 — where per-user logic lives
# Add an authorization block alongside authentication. This is the
# per-user layer — bob can read, but only alice can call the write tool.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: acme-mcp-rbac
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: enterpriseagentgateway.solo.io
    kind: EnterpriseAgentgatewayBackend
    name: acme-mcp-backend
  backend:
    mcp:
      authorization:
        action: Allow
        policy:
          matchExpressions:
          - 'jwt.groups.exists(g, g == "mcp-readers")'
          - 'jwt.sub == "alice@acme.io" || mcp.tool.name != "write_record"'

Why this is a separate concern from scopes

action: Allow
The default. Deny and Require are the other options. The block requires a policy.matchExpressions list.
matchExpressions
CEL over JWT claims and MCP context (mcp.tool.name). This is where "who can call what" is decided — not in scopesSupported.

Scope restriction alone is unreliable — some IdPs ignore requested scopes entirely. Combine the layer-2 ceiling with layer-3 CEL for defence in depth.

Side-by-side comparison

The whole article in one table. Notice how little actually differs — five columns of values over an otherwise-identical structure.

 Entra IDKeycloakAuth0OktaFrontegg
Grant jwt-bearer (OBO) token-exchange token-exchange token-exchange token-exchange
Exchange block tokenExchange.entra elicitation elicitation elicitation elicitation
issuer sts.windows.net/<tenant>/ <host>/realms/<realm> <tenant>/ (trailing /) <org>/oauth2/<id> <env>.frontegg.com
JWKS path /<tenant>/discovery/v2.0/keys /realms/<realm>/…/certs /.well-known/jwks.json /oauth2/<id>/v1/keys /.well-known/jwks.json
audiences api://<app-id> the client ID the API Identifier custom-server Audience the client ID / app ID
exchange mode ExchangeOnly default default default default
discovery provider hint n/a Keycloak Auth0 none none

Per-IdP gotchas

Further reading

About the author. Tom O'Rourke is EMEA Field CTO at Solo.io, working with regulated enterprises across the region on AI infrastructure, service mesh and API gateway architecture.
A note on the values. Hostnames, tenant IDs, client IDs and auth-server IDs are illustrative samples (agentgateway.acme.io, acme.okta.com, and so on). The CRD kinds, field names and grant types are real and current. Verify issuer and JWKS formats against your own tenants — audience behaviour in particular depends on your IdP's mapper and API configuration.