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.
| Leg | What it does | Where it's configured | What 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). | EnterpriseAgentgatewayPolicy → spec.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 IdP | Changes 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 dedicatedtokenExchange.entrablock withmode: 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":
-
The
tokenExchangeHelm values (install time) — turn on the exchange server and set its three validators. ThesubjectValidatorandapiValidatorareremotevalidators pointed at the IdP's JWKS. This is the inbound-validation leg. -
A JWKS
AgentgatewayBackend— a static backend pointing at the IdP host on port 443, withpolicies.tls: {}so the gateway can dial it over HTTPS to fetch keys. Skip the TLS block and the fetch fails. -
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 aSecret.) -
An
EnterpriseAgentgatewayBackendplus itsHTTPRoute— the MCP server the policy targets. Unchanged from any other agentgateway MCP setup, so it's shown once here and not repeated per IdP. -
An
EnterpriseAgentgatewayPolicy—spec.backend.tokenExchangeperforms the swap, andspec.targetRefsattaches it to theEnterpriseAgentgatewayBackendabove.
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: remotewithremoteConfig.urlpointed at the IdP's JWKS. This is what validates the inbound user JWT. actorValidator- Validates the gateway's own ServiceAccount token.
k8sin-cluster; leave it as is. oidc.secretName- A cluster-wide default OAuth Secret. A policy that sets its own
tokenExchange.elicitation.secretNameoverrides 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.
- Register an app for the gateway. Note its Application (client) ID and Directory (tenant) ID — both UUIDs.
- Under Expose an API, set the Application ID URI to
api://<app-id>and add a scope (e.g.agentgateway). - Under API permissions, add the downstream resource you'll exchange for (e.g. Microsoft Graph
.default). - 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
entrablock references it byname+key, so the key name here (client_secret) must matchclientSecretRef.keybelow.
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.
targetRefs→EnterpriseAgentgatewayBackend- The exchange policy attaches to the enterprise MCP backend you defined above.
tokenExchange.entra- The native OBO block.
tenantIdandclientIdare UUIDs;scopeis the downstream resource (here Graph). The gateway sends granturn: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.
- Enable the token-exchange feature on the server (start flag
--features=token-exchange, or the v2 standard token-exchange toggle). - Create a confidential client for the gateway; note its Client ID and Client secret.
- Add an Audience protocol mapper so issued tokens carry the right
aud— Keycloak omits it otherwise, and validation fails. - 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_urlis where the RFC 8693 exchange POST lands;authorize_urlis 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
targetRefs→EnterpriseAgentgatewayBackend- 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-exchangegrant to Keycloak's token endpoint. This per-policy Secret overrides the cluster-wideoidc.secretNamedefault.
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.
- Create an API; its Identifier (e.g.
https://acme-api/agentgateway) becomes your audience. - Create a Machine-to-Machine application for the gateway, authorized for that API; note its Client ID and Client Secret.
- 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'sissexactly. - 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.
- Under Security → API, create (or use) a custom Authorization Server; note its Audience (e.g.
api://acme) and its ID. - Create an OIDC app for the gateway; note its Client ID and Client Secret.
- Enable the Token Exchange grant type on that app.
- 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/tokenshape — the custom server, not/oauth2/v1/token(the org server, which won't work). authorize_url- The matching
/v1/authorizeon 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.
- Note your environment's Frontegg domain under Authentication → SSO → Identity Provider → OpenID Connect Endpoints — that's the host for issuer and JWKS.
- Create (or use) an application; note its Client ID and Secret.
- Set the
audyour tokens should carry via Token management → JWT claims (Frontegg'saudaccepts theclientIdorappId). - 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/tokenon the Frontegg domain — its hosted-login OAuth token endpoint, which advertises thetoken-exchangegrant 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
clientIdorappIdyou 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-knownpaths. The basic shape isresource+scopesSupported+bearerMethodsSupported, shown here. - the issuer-proxy shape
- When the gateway proxies the IdP as the authorization server,
resourceMetadatainstead carriesagentgateway.dev/issuer-proxy(pointing at:7777/oauth-issuer) alongsideauthorizationServersandresource. 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 getsjwt-bearer. That single choice is why Entra has its ownentrablock and the others shareelicitation. 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:
| Layer | Where | Granularity | Per-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.
DenyandRequireare the other options. The block requires apolicy.matchExpressionslist. matchExpressions- CEL over JWT claims and MCP context (
mcp.tool.name). This is where "who can call what" is decided — not inscopesSupported.
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 ID | Keycloak | Auth0 | Okta | Frontegg | |
|---|---|---|---|---|---|
| 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
- Entra — use the v1.0
sts.windows.net/<tenant>/issuer for token validation; the v2.0login.microsoftonline.com/<tenant>/v2.0issuer is for browser OIDC login and won't match access-tokeniss. And it'sjwt-bearer, not generic 8693 — always theentrablock. - Keycloak — you must add an Audience protocol mapper, or issued tokens carry no
audand validation fails. Enable the token-exchange feature on the server; it's off by default on many builds. - Auth0 — the
audclaim only appears when the client requests an API Identifier as the audience. No API Identifier requested → an opaque token you can't validate against your API. - Okta — use a custom authorization server (
/oauth2/<id>/…), never the org server (/oauth2/v1/…). Enable the Token Exchange grant on the app, and matchissuerto whichever domain (org or custom) you actually use. - Frontegg — each environment has its own generated subdomain; read the issuer and JWKS host from the OIDC endpoints panel rather than assuming. Set the
audvia the JWT-claims config, or the validator's audience check won't line up. - All five — the exchange server must be enabled at install (
tokenExchange.enabled: true), or every policy attaches but silently does nothing. - All five — set all three validators in the
tokenExchangeHelm block. LeaveapiValidatorout and the controller fails to start, not with a clear error but at boot. - All five — any IdP reached on 443 needs
policies.tls: {}on its JWKSAgentgatewayBackend. Without it the key fetch fails TLS. In-cluster IdPs on plain HTTP (8080) don't.
Further reading
- JWT, OIDC and on-behalf-of — the four overlapping auth flows, OSS-vs-Enterprise comparison, and the impersonation-vs-delegation token shapes.
- JWT token exchange — hop by hop — an interactive walkthrough watching the
actclaim nest at every agent hop. - RFCs for JWT, OAuth and MCP-auth — the spec stack underneath all of this, including RFC 8693, 8414 and 9728.
- CEL Cookbook for agentgateway — every place CEL appears, including the per-user authorization layer above.
- RFC 8693 — OAuth 2.0 Token Exchange, the normative source.
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.