A practical guide to the authentication patterns supported by agentgateway. Each pattern is tagged with the minimum tier required (OSS or Enterprise), with a "when to use," a YAML snippet, and a diagram.
Why an AI gateway needs its own auth patterns. Christian Posta's Can You Use an API Gateway as an MCP Gateway? frames the gap well: traditional API gateways were built for stateless REST where every request carries its own context in HTTP headers and URL. MCP — the protocol AI tooling is converging on — uses persistent sessions, JSON-RPC bodies, server-initiated SSE callbacks, and multiplexes many backends behind one endpoint. None of that maps cleanly onto an API-gateway feature matrix, and the auth patterns reflect that: dynamic client registration, multi-stage OAuth (downstream + upstream), token exchange with agent-actor claims, and on-demand consent elicitation are all new territory.
Related reading from Christian Posta: API Keys Are a Bad Idea for Enterprise LLM, Agent, and MCP Access · Explaining OAuth Delegation, 'On Behalf Of', and Agent Identity for AI Agents · Enterprise MCP SSO With Microsoft Entra and Agentgateway · Connecting SaaS MCP Servers to Enterprise With Agentgateway · MCP Authorization Patterns for Upstream API Calls · Enterprise Challenges With MCP Adoption
Documentation: docs.solo.io/agentgateway/2.2.x · Enterprise API · OSS API · Helm Values
| Tag | Meaning |
|---|---|
| [OSS] | Configurable using only the OSS agentgateway.dev API surface — works on the standalone OSS Rust binary and on Solo Enterprise clusters. The capability ships in the open-source data plane. |
| [Enterprise] | Requires Solo Enterprise for agentgateway. Depends on one or more enterprise-only components: the EnterpriseAgentgatewayPolicy / AuthConfig CRDs (enterpriseagentgateway.solo.io, extauth.solo.io), the Enterprise external auth service, the built-in STS (Security Token Service), or the Solo Enterprise UI. |
- Architecture at a Glance
- Quick Selection Guide
- At a Glance: All Patterns
- Inbound Authentication
- API Key Auth — [OSS]
- Basic Auth (RFC 7617) — [OSS]
- BYO External Auth (gRPC ext_authz) — [OSS]
- Standard OIDC / JWT Authentication — [OSS]
- Mutual TLS (mTLS) — [OSS]
- MCP OAuth with Dynamic Client Registration — [OSS]
- MCP OAuth — Gateway-Brokered DCR for Auth0 / Okta — [Enterprise]
- Token Exchange
- Gateway-Mediated OIDC + Token Exchange — [Enterprise]
- OBO Delegation (Dual Identity) — [Enterprise]
- OBO Impersonation (Token Swap) — [Enterprise]
- Double OAuth Flow (OIDC + Elicitation) — [Enterprise]
- Eager Upstream OAuth (Gateway as OAuth Issuer) — [Enterprise]
- Upstream / Backend Auth
- Passthrough Token — [OSS]
- Static Secret Injection (Shared Credential) — [OSS]
- Claim-Based Token Mapping — [OSS]
- Credential Gathering
- Elicitation — [Enterprise]
- Decision Flowchart
- Glossary
agentgateway brokers identity in two directions — inbound (who's calling) and outbound (how to reach the destination). These diagrams are the mental model; the pattern sections below give the exact config.
flowchart LR
B[Browsers] & M[MCP Clients] & A[Agents / Apps] -->|inbound| AGW["AgentGateway"]
AGW -->|outbound| LLM[LLMs] & MCP[MCP Servers] & UP[Agents / APIs]
AI clients (Claude Code, Cursor, VS Code) are each their own OAuth client, and the IdPs most enterprises run (Okta, Entra) don't allow open Dynamic Client Registration. Two ways to bridge that:
Build a custom authorization server — real DCR, unique client IDs, per-client revocation, but you build and run it:
flowchart LR
C1[Claude Code] & C2[Cursor] & C3[VS Code] -->|"/register -> unique client_id"| AS["Custom Auth Server<br/>(e.g. auth-mcp)"]
AS <-->|"broker login (per-user)"| IDP{{No-DCR IdP<br/>Okta / Entra}}
AS -->|"own JWT (iss: AS)"| MCP[MCP Servers]
Let agentgateway broker DCR — one shared client ID, IdP token passed through, no per-client revocation, but nothing to build:
flowchart LR
C1[Claude Code] & C2[Cursor] & C3[VS Code] -->|"/register -> ONE shared client_id"| AGW["AgentGateway<br/>(gateway-brokered DCR)"]
AGW <-->|"broker login (per-user)"| IDP{{No-DCR IdP<br/>Okta / Entra}}
AGW -->|"IdP JWT (passthrough)"| MCP[MCP Servers]
Either way, only the registration is shared — every user still logs in at the IdP as themselves, so per-user identity is preserved.
Don't hand a multi-hop agent your broad IdP token. The gateway swaps it for a fresh, scoped, short-lived one before any agent sees it — impersonation keeps just the user (sub), delegation adds an act claim naming the acting agent.
flowchart LR
U[User] -->|"broad IdP JWT<br/>sub: alice - groups: eng, mcp-users, gcp-admin - exp: 1h"| AGW["AgentGateway"]
AGW <-->|mint scoped token| STS{{Gateway STS}}
AGW -->|"scoped JWT<br/>sub: alice - act: agent1 - groups: mcp-users - exp: 1m"| A1[Agent 1] --> A2[Agent 2]
One inbound login, many upstreams — each with its own OAuth identity domain. The gateway gathers a per-user token for each provider (eager at connect, or lazy on first use) and injects it.
flowchart LR
ENTRA{{Microsoft Entra}}
C1[Claude Code]
C2[Cursor]
C3[VS Code]
C1 & C2 & C3 -->|MCP| AGW["AgentGateway<br/>(OAuth issuer · gateway-brokered DCR)"]
ENTRA <-->|"inbound login<br/>one IdP for all clients"| AGW
AGW ==>|elicit| SF[Snowflake MCP]
AGW ==>|elicit| ATL[Atlassian MCP]
AGW ==>|elicit| GL[GitLab MCP]
SF <-->|"upstream OAuth"| SFIDP{{Snowflake OAuth}}
ATL <-->|"upstream OAuth"| ATLIDP{{Atlassian OAuth}}
GL <-->|"upstream OAuth"| GLIDP{{GitLab OAuth}}
Pick the first pattern that matches your scenario:
| If you need to… | Use this pattern | Tier |
|---|---|---|
| Let machine clients (CI, scripts) authenticate with a long-lived secret | API Key Auth | OSS |
| Authenticate humans/services with username + password | Basic Auth | OSS |
| Validate end-user JWTs from an existing IdP (Okta, Auth0, Keycloak, Entra) | Standard OIDC / JWT | OSS |
| Authenticate clients with X.509 certificates (no app-layer credentials) | Mutual TLS | OSS |
| Plug in a custom auth service (existing IAM, MFA, fraud checks) | BYO External Auth | OSS |
| Onboard MCP clients (Claude Code, VS Code) to Keycloak or another IdP that supports open DCR | MCP OAuth + DCR | OSS |
| Onboard MCP clients to Auth0 or Okta (DCR is gated behind their management APIs) | MCP OAuth — Gateway-Brokered DCR | Enterprise |
| Replace the IdP token with a gateway-issued token before reaching agents | Gateway-Mediated OIDC + Token Exchange | Enterprise |
| Carry both user and agent identity to downstream services | OBO Delegation | Enterprise |
| Carry the user's identity only, replacing the IdP token | OBO Impersonation | Enterprise |
| Have one user complete OAuth flows for two different APIs in sequence | Double OAuth Flow | Enterprise |
| One SSO login → multiple upstream APIs (GitHub/GitLab/Atlassian), no Solo UI prompts | Eager Upstream OAuth | Enterprise |
Forward the client's original Authorization header to the backend |
Passthrough Token | OSS |
| Inject a single shared API key for all users into upstream calls | Static Secret Injection | OSS |
| Inject a different upstream key per user, team, or tier | Claim-Based Token Mapping | OSS |
| Prompt users to authorize a third-party API on demand | Elicitation | Enterprise |
| Pattern | Tier | Direction | Identity carried downstream | Best for |
|---|---|---|---|---|
| API Key Auth | OSS | Inbound | Secret-name → x-user-id |
Service accounts, CI/CD |
| Basic Auth | OSS | Inbound | Username | Internal tools, low-friction APIs |
| BYO External Auth | OSS | Inbound | Whatever your service returns | Custom auth, MFA, legacy IAM |
| Standard OIDC / JWT | OSS | Inbound | JWT claims (sub, email, …) |
End-user APIs behind an IdP |
| Mutual TLS | OSS | Inbound + outbound | Client cert SAN/CN | Service-to-service, zero-trust |
| MCP OAuth + DCR | OSS | Inbound | OAuth identity | MCP clients onboarding to open-DCR IdPs (Keycloak) |
| MCP OAuth — Gateway-Brokered DCR | Enterprise | Inbound | OAuth identity | MCP clients onboarding to Auth0 / Okta (admin-gated DCR) |
| Gateway-Mediated OIDC + Token Exchange | Enterprise | Token exchange | Gateway-issued JWT (sub + act) |
Decoupling agents from the IdP |
| OBO Delegation | Enterprise | Token exchange | Gateway-issued JWT (sub + act) |
Auditable user-on-behalf-of-agent |
| OBO Impersonation | Enterprise | Token exchange | Gateway-issued JWT (sub only) |
Hiding IdP tokens from agents |
| Double OAuth Flow | Enterprise | Token exchange | Downstream JWT + upstream token | Agents calling 3rd-party APIs as user |
| Eager Upstream OAuth | Enterprise | Token exchange | Upstream provider token | Single SSO → many third-party APIs |
| Passthrough Token | OSS | Outbound | Original client token | Federated identity, opaque tokens |
| Static Secret Injection | OSS | Outbound | Shared credential | One backend key, many users |
| Claim-Based Token Mapping | OSS | Outbound | Per-claim mapped credential | Tiered access, per-team API keys |
| Elicitation | Enterprise | Outbound | Per-user upstream OAuth token | On-demand 3rd-party authorization |
Patterns that authenticate the client calling the gateway.
When to use: Machine clients (CI jobs, scripts, internal services) that need a stable, long-lived credential. Avoid for end-user traffic — keys leak, don't expire, and can't carry rich identity claims.
Clients authenticate with a static API key stored in a Kubernetes Secret. The apiKeyAuthentication policy validates the key inline in the data plane via secretRef (one Secret) or secretSelector (label-matched Secrets). Per-key metadata flows through to CEL authorization rules.
Heads up: there is no inline-keys mode in the current API — keys must live in a Kubernetes Secret. Each top-level
stringDataentry in the Secret is one key. Use the JSON-object form to attach metadata; use the bare-string form for keys without metadata.
Modes: Strict (require a valid key) or Optional (validate if present, allow if not).
Trade-offs: Simple to roll out. No native expiry — pair with rate limiting and audit logging. Do not use for end-user (human) traffic — see API Keys Are a Bad Idea for Enterprise LLM, Agent, and MCP Access (Christian Posta) — keys prove possession, not legitimacy, and broad-permission keys held by an autonomous agent will eventually be misused.
apiVersion: v1
kind: Secret
metadata:
name: api-keys
namespace: agentgateway-system
labels:
purpose: api-key
stringData:
# JSON-object form — key + arbitrary metadata
alice-laptop: |
{ "key": "k-7f0a3c91-...",
"metadata": {
"user": "alice@example.com",
"team": "platform",
"tier": "paid"
} }
ci-prod-pipeline: |
{ "key": "k-ci-prod-abc123",
"metadata": {
"service": "github-actions",
"tier": "internal"
} }
# Bare-string form — no metadata
bob-cli: "k-bob-456"apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: api-key-auth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-route
traffic:
apiKeyAuthentication:
mode: Strict # Strict | Optional
# Either secretRef (one Secret) OR secretSelector (label-matched Secrets):
secretSelector:
matchLabels:
purpose: api-key
# secretRef:
# name: api-keysDocs: API Key Auth API: APIKeyAuthentication (OSS) · APIKeyAuthentication (Enterprise)
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant Backend
Client->>GW: Request + x-api-key: <key>
GW->>GW: Look up key (inline list or labeled K8s Secret)
alt key valid
GW->>Backend: Forward (+ x-user-id = secret name)
Backend-->>GW: Response
GW-->>Client: Response
else key invalid
GW-->>Client: 401 Unauthorized
end
When to use: Internal tools and low-stakes APIs where you already have htpasswd-style credentials. Not recommended for production end-user traffic — credentials travel on every request and can't be revoked individually without a re-deploy. No path to MFA.
Clients send Authorization: Basic <base64(user:pass)>. The gateway validates against APR1-hashed credentials generated by htpasswd. Storage is mutually exclusive: a users list of htpasswd lines or a secretRef to a Kubernetes Secret holding an htpasswd file.
Modes: Strict (require credentials) or Optional (validate if present).
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: basic-auth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-route
traffic:
basicAuthentication:
mode: Strict # Strict | Optional
realm: agentgateway
# Each entry is one htpasswd line.
# Generate with: htpasswd -nbm alice 'p4ssword'
users:
- "alice:$apr1$dCv...redacted..."
- "bob:$apr1$Hke...redacted..."apiVersion: v1
kind: Secret
metadata:
name: basic-auth-htpasswd
namespace: agentgateway-system
type: Opaque
stringData:
htpasswd: |
alice:$apr1$dCv...redacted...
bob:$apr1$Hke...redacted...
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: basic-auth
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-route
traffic:
basicAuthentication:
mode: Strict
realm: agentgateway
secretRef:
name: basic-auth-htpasswdDocs: Basic Auth API: BasicAuthentication (OSS) · BasicAuthentication (Enterprise)
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant Backend
Client->>GW: Request + Authorization: Basic base64(user:pass)
GW->>GW: Decode + verify against APR1-hashed credentials
alt credentials valid
GW->>Backend: Forward request
Backend-->>GW: Response
GW-->>Client: Response
else credentials invalid
GW-->>Client: 401 + WWW-Authenticate: Basic realm
end
When to use: You already have an authentication service (legacy IAM, MFA gateway, fraud-detection) and want the gateway to delegate decisions instead of duplicating logic. Also fits scenarios needing custom auth logic the built-in patterns don't cover.
Delegate auth to your own service via the Envoy ext_authz protocol (gRPC or HTTP). The gateway sends a CheckRequest for each request; your service returns allow/deny plus optional headers to inject. Maximum flexibility, at the cost of one network hop per request.
The OSS
extAuthfield calls your service directly. The EnterpriseentExtAuthfield uses anAuthConfigreference, which is what enables OAuth/OIDC, JWT-with-introspection, OPA, and other Solo Enterprise extauth modes.
This is the actual shape pulled from a live cluster — the gateway calls a service over HTTP and forwards the validated Authorization header back into the request:
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: ext-auth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-route
traffic:
extAuth:
backendRef:
kind: Service
name: my-auth-service
port: 80
failureMode: FailClosed # FailClosed | FailOpen
http:
path: "/check" # path on your auth service
allowedResponseHeaders:
- Authorization # headers to forward from auth response into requestspec:
traffic:
extAuth:
backendRef:
kind: Service
name: my-grpc-auth-service
port: 9001
failureMode: FailClosed
grpc:
context: # static context fields sent in CheckRequest
environment: prodDocs: BYO Ext Auth Service API: ExtAuth (OSS) · EnterpriseAgentgatewayExtAuth
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant ExtAuth as Your Auth Service
participant Backend
Client->>GW: Request
GW->>ExtAuth: CheckRequest (gRPC ext_authz or HTTP)
alt ExtAuth allows
ExtAuth-->>GW: ALLOW + headers to inject
GW->>Backend: Forward request (+ injected headers)
Backend-->>GW: Response
GW-->>Client: Response
else ExtAuth denies
ExtAuth-->>GW: DENY
GW-->>Client: 403
end
When to use: End-user APIs where users authenticate at an existing IdP (Okta, Auth0, Keycloak, Entra ID) and present a JWT bearer token. Default modern choice for human-driven traffic.
The client obtains a JWT from an external OIDC provider (e.g., via Authorization Code Flow) and presents it as a bearer token. The gateway validates the JWT signature against the provider's JWKS endpoint, plus issuer and audience claims. The gateway does not participate in the OIDC redirect flow itself — it only validates tokens.
Three ways to handle the OIDC redirect, mapped to the three patterns in this guide:
| Who runs the OIDC redirect | Tier | Pattern |
|---|---|---|
| The CLIENT (browser app, CLI with PKCE, MCP client) | OSS | This pattern — gateway just validates the JWT the client brings |
| YOUR ext-auth service (custom or off-the-shelf) | OSS | BYO External Auth — gateway delegates via extAuth |
| The Enterprise ext-auth service (built-in OIDC + token exchange) | Enterprise | Gateway-Mediated OIDC + Token Exchange |
Modes: Strict (require valid JWT), Optional (validate if present), Permissive (never reject).
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: jwt-auth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: my-route
traffic:
jwtAuthentication:
mode: Strict
providers:
- issuer: "https://keycloak.example.com/realms/agents"
audiences: ["my-ai-application"]
jwks:
remote:
jwksPath: "/realms/agents/protocol/openid-connect/certs"
cacheDuration: "5m"
backendRef:
kind: Service
name: keycloak
namespace: keycloak
port: 8080Pulled from a live cluster — useful for tests and air-gapped setups:
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: jwt-test
namespace: jwt-test
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: echo
traffic:
jwtAuthentication:
mode: Strict
providers:
- issuer: test-issuer
audiences: [test]
jwks:
inline: |
{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key-1","n":"...","e":"AQAB"}]}
- issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
audiences: ["api://<client-id>"]
jwks:
remote:
jwksPath: "/common/discovery/v2.0/keys"
backendRef:
kind: Service
name: entra-id-proxy
namespace: auth-system
port: 443
authorization:
action: Allow
policy:
matchExpressions:
- jwt.sub == "agent-service" # CEL on validated claimsDocs: Set up JWT Auth · JWT Auth for MCP Services · Keycloak as IdP API: JWTAuthentication (OSS) · JWTAuthentication (Enterprise)
sequenceDiagram
actor Client
participant IdP as OIDC Provider
participant GW as Agent Gateway
participant Backend
Client->>IdP: Login (Authorization Code Flow, out-of-band)
IdP-->>Client: JWT (access_token / id_token)
Client->>GW: Request + Authorization: Bearer <JWT>
GW->>IdP: Fetch JWKS (cached per cacheDuration)
GW->>GW: Validate signature, iss, aud, exp
alt valid
GW->>Backend: Forward request
Backend-->>GW: Response
GW-->>Client: Response
else invalid
GW-->>Client: 401 (no bearer / bad signature)
end
When to use: Service-to-service traffic in a zero-trust network, or when you want a credential bound to a workload identity (cert) rather than a user. Combine
FrontendTLS+BackendTLSfor end-to-end encryption.
Two independent TLS features that often get bundled in conversation but configure separately:
- FrontendTLS (inbound mTLS): clients present an X.509 cert at the TLS handshake; the gateway validates it against
caCertificateRefs. Modes:Strict(default — reject invalid/missing certs) orAllowInsecureFallback. This is the mutual half. - BackendTLS (outbound TLS origination): the gateway opens a TLS connection to the backend and verifies the backend's server cert. By default this is one-way TLS — the gateway doesn't present its own client cert unless you configure that separately. Configured either as a standalone Gateway-API
BackendTLSPolicyor inline viabackend.tlson a policy.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: agentgateway-proxy
namespace: agentgateway-system
spec:
gatewayClassName: agentgateway
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: server-tls
frontendValidation:
caCertificateRefs: # CA used to validate client certs
- kind: ConfigMap
name: client-caapiVersion: gateway.networking.k8s.io/v1
kind: BackendTLSPolicy
metadata:
name: my-backend-tls
namespace: agentgateway-system
spec:
targetRefs:
- group: ""
kind: Service
name: secured-upstream
validation:
hostname: api.upstream.example.com
caCertificateRefs:
- kind: ConfigMap
name: upstream-ca
group: ""apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: backend-tls-inline
namespace: agentgateway-system
spec:
targetRefs:
- group: ""
kind: Service
name: secured-upstream
backend:
tls:
hostname: api.upstream.example.com
# insecureSkipVerify: All | Hostname (omit for full verification)
# Or use system roots: wellKnownCACertificates: SystemDocs: Set up mTLS (FrontendTLS) · BackendTLS API: FrontendTLS (OSS) · BackendTLS (OSS) · BackendTLS (Enterprise)
sequenceDiagram
actor Client
participant GW as Agent Gateway (TLS terminator)
participant Backend
Client->>GW: TLS ClientHello + client cert
GW->>GW: FrontendTLS — validate client cert against CA
alt cert valid
GW->>Backend: BackendTLS — open new TLS conn, verify backend cert
Backend-->>GW: Response (over TLS)
GW-->>Client: Response (over TLS)
else cert missing or invalid
GW--xClient: TLS handshake failure
end
When to use: You're exposing MCP servers and your clients (Claude Code, VS Code extensions, in-house agents) need to onboard automatically without an admin pre-registering OAuth credentials.
The gateway exposes the MCP server's OAuth metadata at .well-known/oauth-protected-resource/<path> and .well-known/oauth-authorization-server/<path>, validates inbound bearer tokens, and brokers Dynamic Client Registration (RFC 7591) at the configured IdP. Built-in adapters: keycloak, auth0, okta (added in PR #1831 — same shape as Auth0: audience injection + JWKS at .well-known/jwks.json + CORS-proxied DCR endpoint), or omit provider for spec-compliant IdPs. See the Auth0 / Okta caveat below for what these adapters do (and don't) solve.
OAuth normally assumes a human admin pre-registers each client application in the IdP and ships its client_id/client_secret to the client. That model breaks for MCP: every developer's Claude Code, Cursor, VS Code, or in-house agent installation is a separate "client" — there is no admin in the loop to register each one.
Dynamic Client Registration (RFC 7591) lets clients self-register at runtime: the client POSTs its metadata to registration_endpoint, the IdP responds with a fresh client_id (and optionally client_secret), and the client uses those for the rest of the OAuth flow. The MCP authorization spec requires a registration_endpoint in the authorization-server metadata for exactly this reason.
Where real DCR works. Keycloak and other spec-compliant IdPs that expose an unauthenticated registration endpoint. See the YAML below.
Where the OSS adapters help. Auth0 and Okta now have first-class OSS adapters (provider: { auth0: {} } and provider: { okta: {} }). They handle the protocol-level integration smoothly — audience injection on the authorize endpoint, CORS-proxied DCR endpoint, JWKS at .well-known/jwks.json, OIDC discovery URL for metadata (Okta doesn't support RFC 8414 path-based issuer format).
Where they DON'T help. Auth0 and Okta still gate DCR behind their management APIs / Initial Access Tokens — admin-token-gated, rate-limited, and creating a fresh app in the IdP dashboard per call. The OSS adapters make the protocol bits work; they don't change the underlying Auth0/Okta DCR economics. Fine for test deployments and small fleets; untenable for a developer fleet running Claude Code + Cursor + VS Code + in-house agents.
Enterprise honesty. As Christian Posta argues in Understanding MCP Authorization With Dynamic Client Registration, the MCP spec assumes anonymous client registration, which "opens up challenges around monitoring, auditing, and revocation." Enterprise security teams typically reject anonymous trust. So while DCR is the right answer for hobbyist Keycloak setups and the open ecosystem, enterprises usually need the Gateway-Brokered DCR variant where the gateway substitutes one pre-vetted IdP application that every MCP client uses.
Real-world gotchas (per Posta):
- Keycloak CORS: Keycloak doesn't enable CORS by default on its metadata endpoints, so MCP Inspector and other browser clients can't fetch them directly — a reverse proxy is required.
- Scope overreach: Some MCP clients (notably
mcp-inspectorhistorically) pass all server scopes to the registration endpoint, violating least-privilege. Verify your client doesn't do this.
Gateway-brokered DCR. The gateway stands in as the registration endpoint the IdP doesn't offer: registration_endpoint in the authorization-server metadata resolves to the gateway, and /oauth-issuer/register returns a single pre-registered IdP client_id/client_secret that every MCP client receives. Per-client revocation goes away — there's one IdP application backing the whole fleet — but per-user identity is preserved: each user still completes the IdP's authorization-code flow themselves, and the IdP-issued JWT is what the MCP backend validates. See MCP OAuth — Gateway-Brokered DCR for Auth0 / Okta.
OSS vs. Enterprise: The MCP authentication broker is in OSS (validated against the OSS proto and
examples/mcp-authentication/config.yaml). DCR support comes from the IdP — agentgateway just brokers the OAuth metadata and validates JWTs. The Solo Enterprise UI is not required, but if you also want a managed admin UI for MCP server registration and a single per-cluster OAuth experience, that is part of Solo Enterprise.
Auth0 / Okta caveat: The OSS
provider: { auth0: {} }andprovider: { okta: {} }adapters handle the protocol mismatch — audience injection, CORS-proxied registration endpoint, JWKS path, OIDC-discovery-style metadata. They do not change the fact that Auth0's and Okta's DCR endpoints are gated behind their Management APIs / Initial Access Tokens, rate-limited, and create a new application per MCP client. So the adapters work fine for test/lab deployments and small fleets, but not for production developer fleets running Claude Code + Cursor + VS Code. For those, use MCP OAuth — Gateway-Brokered DCR for Auth0 / Okta — the gateway hosts its own OAuth AS and substitutes pre-registered credentials at/oauth-issuer/register.
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: mcp-oauth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: mcp-server
backend:
mcpAuthentication:
mode: Strict
issuer: "http://keycloak.example.com/realms/mcp"
audiences: ["mcp_proxy"]
jwks:
url: "http://keycloak.example.com/realms/mcp/protocol/openid-connect/certs"
provider:
keycloak: {} # adapter for non-spec-compliant Keycloak
resourceMetadata:
resource: "https://gateway.example.com/keycloak/mcp"
scopesSupported: [openid, profile, offline_access]
bearerMethodsSupported: [header]# yaml-language-server: $schema=https://agentgateway.dev/schema/config
binds:
- port: 3000
listeners:
- routes:
- matches:
- path: { exact: /mcp }
- path: { exact: /.well-known/oauth-protected-resource/mcp }
- path: { exact: /.well-known/oauth-authorization-server/mcp }
policies:
mcpAuthentication:
mode: strict
issuer: http://keycloak:7080/realms/mcp
audiences: [mcp_proxy]
jwks: { url: http://keycloak:7080/realms/mcp/protocol/openid-connect/certs }
provider: { keycloak: {} }
resourceMetadata:
resource: http://localhost:3000/mcp
scopesSupported: [openid, profile, offline_access]
backends:
- mcp:
targets:
- name: tools
stdio:
cmd: npx
args: ["@modelcontextprotocol/server-everything"]sequenceDiagram
participant MCP as MCP Client (Claude Code, VS Code)
participant GW as Agent Gateway
participant IdP as OIDC Provider (Keycloak / spec-compliant)
MCP->>GW: GET /.well-known/oauth-protected-resource/<path>
GW-->>MCP: Resource metadata
MCP->>GW: GET /.well-known/oauth-authorization-server/<path>
GW-->>MCP: Auth-server metadata (adapted for non-spec providers)
MCP->>IdP: POST /register (RFC 7591 Dynamic Client Registration)
IdP-->>MCP: client_id + client_secret (unique per MCP client)
MCP->>IdP: Authorization Code Flow (PKCE)
IdP-->>MCP: Bearer token
MCP->>GW: Call MCP endpoint with Bearer token
GW->>GW: Validate JWT (issuer, audience, JWKS)
GW-->>MCP: MCP response
When to use: You're exposing MCP servers and your IdP is Auth0, Okta, Microsoft Entra, or another IdP that gates DCR behind a management API. Real DCR doesn't scale to a fleet of MCP clients (Claude Code, Cursor, VS Code, in-house agents) on these IdPs. For Keycloak and other open-DCR IdPs, use MCP OAuth + DCR instead.
Christian Posta sets the bar for this pattern in Enterprise MCP SSO With Microsoft Entra and Agentgateway: "Any internal enterprise MCP client / AI agent that communicates to a [remote] MCP server should be secured with enterprise SSO." The catch is that MCP clients — VS Code, Cursor, Claude — aren't browser applications, and the official MCP OAuth spec assumes browser-based interaction. This pattern bridges the gap.
The gateway hosts its own OAuth Authorization Server at /oauth-issuer/. From an MCP client's perspective there is exactly one OAuth issuer: the gateway. /oauth-issuer/register returns a single pre-registered IdP client_id/client_secret to every MCP client — no IdP-dashboard churn, no Management-API DCR. The gateway then brokers the authorization code flow to the IdP, and the IdP-issued JWT is validated at the MCP backend.
One-time setup (per Posta's Entra walkthrough — adapt to your IdP):
- Create an IdP application (Entra App Registration, Auth0 Application, Okta App) representing the agentgateway.
- Expose an API scope (e.g.,
mcp_access). - Pre-consent known clients. VS Code's baked-in MCP client ID is
aebc6443-996d-45c2-90f0-388ff96faa56— useful for pre-consenting. - Configure custom clients via additional IdP applications with proper redirect URIs.
Eager vs lazy: "Eager" here means the OAuth flow runs at MCP connect time — when the client first establishes the MCP session — not lazily on each tool call. Once the session is up, every tool call inside it reuses the same JWT. Contrast with the lazy patterns Double OAuth Flow and Elicitation, where the user is prompted on demand. See Background: DCR and MCP for what DCR is and why gateway-brokered DCR exists.
Related: If you also need per-upstream-provider tokens (each MCP backend gets its own GitHub / GitLab / Atlassian token), see Eager Upstream OAuth — same
/oauth-issuer/machinery, plus per-backend token exchange.
This pattern shares its controller-side configuration with Eager Upstream OAuth's Helm values: the tokenExchange.enabled block, the KGW_OAUTH_ISSUER_CONFIG env var, the oauth-issuer HTTPRoute, and the STS_URI env on the proxy. Skip the multi-upstream Variants 1 and 2 — no per-upstream secret, no backend.tokenExchange policy. The single pre-registered IdP credentials live in client_config.clients and are returned to every MCP client at /oauth-issuer/register.
sequenceDiagram
autonumber
actor User
participant MCP as MCP Client
participant GW as Agent Gateway<br/>+ /oauth-issuer
participant IdP as IdP<br/>(Auth0 / Okta)
participant Tool as MCP Server
MCP->>GW: GET /.well-known/oauth-authorization-server/mcp
GW-->>MCP: AS metadata — registration_endpoint, authorize_url, token_url<br/>all point at gateway's /oauth-issuer/*
MCP->>GW: POST /oauth-issuer/register (RFC 7591 DCR)
Note over GW: Gateway-Brokered DCR — returns pre-registered client_id from<br/>KGW_OAUTH_ISSUER_CONFIG.client_config.clients<br/>(no call to IdP)
GW-->>MCP: client_id (+ client_secret)
MCP->>GW: GET /oauth-issuer/authorize (PKCE)
GW-->>MCP: 302 to IdP /authorize<br/>(using downstream_server.client_id/secret)
User->>IdP: SSO login
IdP-->>GW: Callback /oauth-issuer/callback/... (code)
GW->>IdP: POST /token (code -> JWT)
IdP-->>GW: IdP-issued JWT
GW-->>MCP: Gateway auth code -> IdP JWT
MCP->>GW: Call /mcp with Bearer (IdP JWT)
GW->>GW: Validate JWT (issuer, audience, IdP JWKS)
GW->>Tool: Forward request with IdP JWT
Tool-->>GW: Response
GW-->>MCP: Response
The DCR substitution at step 4 is the whole point of the pattern: registration_endpoint resolves to the gateway, not the IdP, so MCP clients never hit the IdP's rate-limited Management-API DCR.
The standard JWT-validation shape with one critical annotation that flips the AS metadata to point at the gateway:
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: mcp-auth-eager
spec:
targetRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: mcp-backend
backend:
mcp:
authentication:
mode: Strict
issuer: https://your-tenant.auth0.com/ # trailing slash matters for Auth0
audiences: [https://your-api/]
jwks:
backendRef:
name: idp-jwks
kind: AgentgatewayBackend
group: agentgateway.dev
jwksPath: .well-known/jwks.json # no leading slash — controller appends one
resourceMetadata:
# Critical: tells the gateway to serve the eager-OAuth issuer's metadata at
# .well-known/oauth-authorization-server/<path> so registration_endpoint points
# at the gateway, not the IdP. Without this, MCP clients DCR against the IdP
# directly and hit the rate-limit / Mgmt-API problem this pattern exists to solve.
agentgateway.dev/issuer-proxy: http://enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777/oauth-issuer
authorizationServers: [https://gateway.example.com/mcp]
resource: https://gateway.example.com/mcpThe pattern is identical for both IdPs; the per-IdP setup details differ in non-obvious ways. Both columns below are validated against runnable workshop labs.
| Concern | Auth0 | Okta |
|---|---|---|
issuer value |
https://<tenant>.auth0.com/ — trailing slash required. Auth0 puts it in the iss claim and the policy compares literally. |
https://<domain>/oauth2/<authz-server-id> (typically default) — no trailing slash. The org server (no /oauth2/...) shifts every path; use a custom authz server. |
| Audience handling | KGW_OAUTH_ISSUER_CONFIG.downstream_server has no audience field, so Auth0 won't mint API-scoped tokens unless you set the audience as the tenant default (Settings → API Authorization Settings → Default Audience), or drop audiences from the policy and validate issuer-only. |
aud comes from the authz server's "Audience" setting (Security → API → Authorization Servers → your-server → Settings). No ?audience= injection needed; just match the policy's audiences to whatever the server is issuing. |
JWKS path (in mcp.authentication.jwks.jwksPath) |
.well-known/jwks.json |
oauth2/<authz-server-id>/v1/keys (custom authz server) — oauth2/v1/keys if you're using the org server (issuer = https://<domain>). No leading slash — the controller appends one. |
| Callback URL registration | Both …/oauth-issuer/callback/downstream AND …/oauth-issuer/callback/upstream must be in the Auth0 app's Allowed Callback URLs. Registering only one yields invalid_request: callback url not allowed after login. |
Both required as Sign-in redirect URIs. Single-callback rejection: The 'redirect_uri' parameter must be a Login redirect URI. |
Walkthroughs (runnable end-to-end with MCP Inspector):
- Auth0 — Lab 003 (gist) ·
mcp-eager-auth-auth0.md(workshop)- Okta —
mcp-eager-auth-okta.md(workshop)Both labs cover HTTPS/cert setup, Postgres, the full Helm-values block, MCP Inspector flow, and a CLI check that
registration_endpointactually got rewritten to the gateway.
Patterns that take an inbound token and transform it before it reaches downstream services. All require Solo Enterprise — the BackendAuthPolicy.tokenExchange field exists only in the Enterprise proto.
When to use: You want agents to trust only the gateway's STS issuer — never the external IdP directly. Useful for decoupling agents from IdP changes and for centralizing claim shaping.
The gateway handles OIDC authentication itself (via entExtAuth + an AuthConfig of type oauth2.oidcAuthorizationCode), then automatically exchanges the IdP token (via RFC 8693) before forwarding to the agent. The agent only ever sees the STS-issued JWT.
apiVersion: v1
kind: Secret
metadata:
name: idp-client-secret
namespace: agentgateway-system
type: extauth.solo.io/oauth
stringData:
client-secret: <idp-client-secret>
---
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
name: corporate-sso
namespace: agentgateway-system
spec:
configs:
- oauth2:
oidcAuthorizationCode:
appUrl: "https://gateway.example.com"
callbackPath: /callback
clientId: <gateway-client-id>
clientSecretRef:
name: idp-client-secret
namespace: agentgateway-system
issuerUrl: "https://login.microsoftonline.com/<tenant>/v2.0/"
scopes: [openid, profile, email]
headers:
idTokenHeader: jwt
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: oidc-and-exchange
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agentgateway-proxy
traffic:
entExtAuth:
authConfigRef: { name: corporate-sso }
backendRef:
name: ext-auth-service-enterprise-agentgateway
namespace: agentgateway-system
port: 8083
backend:
tokenExchange:
mode: ExchangeOnly # Variant A: built-in STS issues sub+act JWTUses agentgateway's built-in token exchange server. mode: ExchangeOnly. STS issues a JWT with sub (user) + act (agent).
sequenceDiagram
actor User
participant GW as Agent Gateway (Proxy)
participant IdP as OIDC Provider
participant STS as AGW Built-in STS
participant Agent as Agent / MCP Server
User->>GW: Request (no session cookie)
GW-->>User: 302 to IdP /authorize
User->>IdP: Login + submit credentials
IdP-->>GW: Callback with code → POST /token → User JWT
GW-->>User: Set session cookie
GW->>STS: POST /token (subject_token=user JWT, actor_token=K8s SA)
STS-->>GW: Exchanged JWT (sub=user, act=agent)
GW->>Agent: Authorization: Bearer <exchanged JWT>
Agent-->>GW: Response
GW-->>User: Result
Input — User JWT (issued by the IdP, received by the gateway after the OIDC dance):
Output — Exchanged JWT (issued by the AGW Built-in STS, forwarded to the agent):
// Header
{
"alg": "RS256",
"kid": "agw-sts-key-1",
"typ": "JWT"
}
// Payload — RFC 8693 §4.1
{
"iss": "https://agentgateway.example.com/sts", // CHANGED: now the gateway's STS
"sub": "u-7f0a3c91", // PRESERVED: same user
"aud": "mcp-server.agents.svc", // CHANGED: now the agent/MCP server
"exp": 1777296700, // SHORTER: typically minutes, not hours
"iat": 1777296400,
"scope": "mcp:invoke", // RESHAPED: only what the agent needs
"act": { // NEW: RFC 8693 actor claim
"sub": "system:serviceaccount:agentgateway-system:agent-runtime",
"iss": "https://kubernetes.default.svc"
}
}What changed and why:
| Field | User JWT | Exchanged JWT | Why |
|---|---|---|---|
iss |
IdP | AGW STS | Agent only trusts the STS issuer — IdP is invisible to it |
sub |
user id | user id | Preserved so downstream policy can authorize the user |
aud |
gateway client_id | agent / MCP server id | Token is now scoped to the resource it's actually calling |
exp |
hours | minutes | Exchange-only tokens are short-lived; gateway re-exchanges on demand |
scope |
full IdP scopes | minimum needed | Reshaped — agent doesn't need email, profile |
act |
(none) | {sub: agent SA, iss: K8s} |
RFC 8693 actor claim — auditable agent-on-behalf-of-user |
| signature | IdP private key | AGW STS private key | Agent verifies against <sts>/.well-known/jwks.json |
OBO Impersonation produces the same shape as the Exchanged JWT above but omits the
actclaim — downstream services see only the user.
Uses Microsoft Entra ID as an external token-exchange provider via Entra's OBO flow (urn:ietf:params:oauth:grant-type:jwt-bearer).
spec:
backend:
tokenExchange:
mode: ExchangeOnly
entra:
clientId: <gateway-app-client-id>
clientSecretRef: { name: entra-client-secret }
tenantId: <tenant-id>
scope: "https://graph.microsoft.com/.default"sequenceDiagram
actor User
participant GW as Agent Gateway (Proxy)
participant Entra as Entra ID
participant Agent as Agent / Target API
User->>GW: Request (no session)
GW-->>User: 302 to Entra /authorize
User->>Entra: Login
Entra-->>GW: Callback → User JWT (aud = gateway app)
GW->>Entra: POST /token (grant=jwt-bearer, scope=Graph, requested_token_use=on_behalf_of)
Entra-->>GW: Resource-scoped JWT (aud = Microsoft Graph, signed by Entra)
GW->>Agent: Authorization: Bearer <Entra-issued token>
Agent-->>GW: Response
GW-->>User: Result
Unlike Variant A, the exchanged token here is issued by Entra, not by agentgateway. The agent validates against Entra's JWKS (
https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys).
Input — User JWT (issued by Entra ID, audience = the gateway's app registration):
// Header
{
"alg": "RS256",
"kid": "nOo3ZDrODXEK1jKWhXslHR_KXEg",
"typ": "JWT"
}
// Payload — Entra v2.0 access token
{
"iss": "https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0",
"aud": "api://4ab8e9b1-...-gateway-app", // the gateway's Entra app client_id
"sub": "AAAAAA...user-pairwise-id",
"oid": "5f6c2c8d-3e4d-4f8e-9b0a-1c2d3e4f5a6b", // stable Entra user object id
"tid": "72f988bf-86f1-41af-91ab-2d7cd011db47", // tenant id
"preferred_username": "alice@contoso.com",
"name": "Alice Anderson",
"scp": "User.Access", // Entra v2 uses `scp` not `scope`
"ver": "2.0",
"exp": 1777300000,
"iat": 1777296400,
"nbf": 1777296400
}OBO request the gateway makes to Entra (server-to-server, no user redirect):
POST https://login.microsoftonline.com/72f988bf-.../oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
client_id=4ab8e9b1-...-gateway-app
client_secret=<from clientSecretRef>
assertion=<the User JWT above>
scope=https://graph.microsoft.com/User.Read
requested_token_use=on_behalf_ofOutput — Exchanged JWT (issued by Entra, audience = the target resource):
// Header
{
"alg": "RS256",
"kid": "nOo3ZDrODXEK1jKWhXslHR_KXEg", // still Entra's signing key
"typ": "JWT"
}
// Payload — Entra-issued, scoped to Microsoft Graph
{
"iss": "https://login.microsoftonline.com/72f988bf-.../v2.0",
"aud": "https://graph.microsoft.com", // CHANGED: target resource, not the gateway
"sub": "AAAAAA...graph-pairwise-id", // CHANGED: pairwise sub for the new audience
"oid": "5f6c2c8d-3e4d-4f8e-9b0a-1c2d3e4f5a6b", // PRESERVED: stable user identity
"tid": "72f988bf-86f1-41af-91ab-2d7cd011db47",
"preferred_username": "alice@contoso.com",
"name": "Alice Anderson",
"scp": "User.Read", // CHANGED: only the scope requested in the OBO call
"azp": "4ab8e9b1-...-gateway-app", // NEW: the app that performed the OBO (the gateway)
"azpacr": "1", // NEW: auth method used (1 = client_secret)
"ver": "2.0",
"exp": 1777300000,
"iat": 1777296400,
"nbf": 1777296400
}What changed and why:
| Field | User JWT | Exchanged JWT | Why |
|---|---|---|---|
iss |
Entra (your tenant) | Entra (your tenant) | Same issuer in both — Entra mints both tokens |
aud |
gateway app id | target resource (e.g. Graph) | Token is now scoped to the resource being called |
sub |
pairwise for gateway | pairwise for target resource | Entra issues a new pairwise sub per audience |
oid |
user object id | user object id | Stable across audiences — use this for "who is this user" |
scp |
full IdP scopes | only OBO-requested scope | Entra honors the scope= parameter in the OBO call |
azp |
(none) | gateway app id | RFC 7519 "authorized party" — records which app did the OBO |
act |
(none) | (none) | Entra OBO does not emit RFC 8693 act — it's a Microsoft-flavored exchange, not the standard one |
| signature | Entra | Entra | Agent verifies against Entra JWKS, not AGW STS |
Operational consequence: Variant B's agent must be configured to trust Entra as the JWT issuer. With Variant A the agent trusts the AGW STS and the IdP is invisible — that's the main reason teams choose Variant A even when they're already on Entra.
Docs: OBO Token Exchange · Set up JWT Auth API / Helm: tokenExchange Helm values
When to use: You need an audit trail showing both who the user is and which agent acted on their behalf. Common in agentic systems where multiple agents touch a single user request and you need attribution at each hop.
The agent exchanges the user's JWT for a delegated OBO token via RFC 8693. The user's JWT must include a may_act claim authorizing the agent. The STS validates the user JWT and the agent's Kubernetes service-account token, then issues a new JWT (signed by agentgateway) containing both sub (user) and act (agent). Downstream services trust the agentgateway issuer and can enforce policies on either identity.
Why both identities matter. Christian Posta makes the case in Explaining OAuth Delegation, 'On Behalf Of', and Agent Identity for AI Agents: "OAuth-style flows already solve delegation. What OAuth never considered was autonomy and non-deterministic applications making decisions." Agents don't propagate user intent the way a traditional client does — they CREATE intent. Without a separate
actidentifier, the audit log can't tell agent action from direct user action, and you can't revoke 'just this agent' without revoking the user. Multi-hop agent chains preserve full causality via nested actor claims: 'agent A called agent B which is why agent B is calling API foo' becomes traceable. RFC 8693 supports this natively; Microsoft Entra exposes the same concept viaxms_act_fct.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: obo-delegation
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: agent-route
traffic:
jwtAuthentication: # validate inbound user JWT
mode: Strict
providers:
- issuer: "https://idp.example.com"
audiences: ["agent-gateway"]
jwks:
remote:
jwksPath: "/.well-known/jwks.json"
backendRef: { kind: Service, name: idp, namespace: idp-system, port: 443 }
backend:
tokenExchange:
mode: ExchangeOnly # require may_act in the user JWTThe user's JWT must carry
may_act: { sub: "agent-service-account" }. STS configuration is set via HelmtokenExchange.*values.
Docs: OBO Token Exchange · About OBO & Elicitations API / Helm: tokenExchange Helm values
sequenceDiagram
autonumber
actor User
participant Agent
participant GW as Agent Gateway (Proxy)
participant STS as AGW Built-in STS
participant Tool as MCP Tool Server
User->>Agent: Request with user JWT (contains may_act)
Agent->>GW: Authorization: Bearer <user JWT>
GW->>STS: POST /token (subject_token=user JWT, actor_token=agent K8s SA)
STS-->>GW: OBO token (sub=user, act.sub=agent)
GW->>Tool: Call with OBO token
Tool-->>GW: Response
GW-->>Agent: Response
Agent-->>User: Result
Where the work happens: the gateway proxy intercepts the agent's outbound call, posts to the STS (
grant_type=urn:ietf:params:oauth:grant-type:token-exchange), receives the new JWT, and forwards it to the tool. The STS verifies thatmay_act.subin the user JWT matches thesubof the actor token. The tool validatessub(user) andact(agent) before serving the request.
OBO Delegation is the only pattern where the input JWT must carry a special claim — may_act — that the user pre-authorizes for a specific agent. There are three tokens in play: the user JWT, the agent's actor token, and the resulting OBO JWT.
Input 1 — User JWT (with may_act)
The user obtains this from the IdP. Critically, the IdP must be configured to mint a may_act claim naming the agent service account that's allowed to act on the user's behalf.
// Header
{
"alg": "RS256",
"kid": "idp-key-2026-04",
"typ": "JWT"
}
// Payload
{
"iss": "https://login.example.com/realms/agents",
"sub": "u-7f0a3c91", // the human user
"aud": "agent-platform",
"exp": 1777300000,
"iat": 1777296400,
"email": "alice@example.com",
"scope": "openid profile mcp:invoke",
"may_act": { // KEY: pre-authorizes a specific actor
"sub": "system:serviceaccount:agents:agent-runtime",
"iss": "https://kubernetes.default.svc"
}
}Without
may_act, the STS rejects the exchange withinvalid_request. This claim is what makes Delegation opt-in by the user instead of an unrestricted swap.
Input 2 — Actor token (Kubernetes ServiceAccount JWT, projected into the agent pod)
The agent pod mounts a projected SA token at /var/run/secrets/tokens/... and sends it as the actor_token:
// Payload (Kubernetes-issued)
{
"iss": "https://kubernetes.default.svc",
"sub": "system:serviceaccount:agents:agent-runtime", // MUST match user JWT's may_act.sub
"aud": ["agent-gateway-sts"], // audience-bound to the STS
"exp": 1777296700,
"iat": 1777296400,
"kubernetes.io": {
"namespace": "agents",
"serviceaccount": { "name": "agent-runtime", "uid": "..." },
"pod": { "name": "agent-runtime-7d9c-x4f2", "uid": "..." }
}
}The STS validates this against the Kubernetes API (TokenReview).
Output — OBO Token (issued by AGW STS)
After the STS validates both inputs and confirms may_act.sub == actor_token.sub, it mints:
// Header
{
"alg": "RS256",
"kid": "agw-sts-key-1",
"typ": "JWT"
}
// Payload — RFC 8693 §4.1 with full delegation chain
{
"iss": "https://agentgateway.example.com/sts", // CHANGED: AGW STS, not the IdP
"sub": "u-7f0a3c91", // PRESERVED: the user
"aud": "mcp-tool-server.agents.svc", // CHANGED: scoped to the MCP tool server
"exp": 1777296700,
"iat": 1777296400,
"scope": "mcp:invoke",
"act": { // NEW: full delegation chain
"sub": "system:serviceaccount:agents:agent-runtime",
"iss": "https://kubernetes.default.svc"
}
}What's distinctive vs. Gateway-Mediated OIDC (Variant A):
| Aspect | Gateway-Mediated OIDC (Variant A) | OBO Delegation |
|---|---|---|
| Who triggers the exchange | The gateway, on every backend call | The agent (one hop deeper in the call chain) |
| User JWT requirement | Any valid OIDC token | Must contain may_act |
| Who the user authenticates to | The gateway (gateway is the OAuth client) | The IdP directly (user holds the JWT) |
| Actor token | The agent's K8s SA token, but may_act is not required |
K8s SA token + must satisfy may_act |
Output act claim |
Set to whatever actor was supplied | Cryptographically tied to a user-authorized SA |
| Trust story | "Gateway speaks for users" | "User explicitly delegated to this agent" |
OBO Impersonation uses the same exchange but without an actor token and produces the output JWT with
subonly (noact).
When to use: You want downstream services to see only the user's identity, not the agent's, and you want to keep the original IdP token off the wire after the gateway. Cleanest for systems already designed around end-user JWTs.
Same as Delegation, but without an actor token. The STS validates the user JWT and issues a new JWT (signed by agentgateway) with the same sub and scopes — no act claim. Original IdP token is replaced.
The
substays the same on purpose — downstream services still need to know who the user is, so authorization policy keyed onsubkeeps working. What changes is everything aroundsub:
Field IdP token Exchanged token Why it matters issexternal IdP gateway STS Agents only trust one issuer; IdP becomes invisible audgateway client_id the specific backend Token can't be replayed against the IdP or other resources exphours minutes Smaller blast radius if it leaks scopefull IdP scopes only what the agent needs Reduces what an attacker can do with a stolen token signature IdP private key gateway STS key Agent verifies against gateway JWKS, not the IdP One nuance worth flagging — see Posta's Explaining OAuth Delegation for AI Agents: pure Impersonation hides important information about WHO decided to act. Downstream services lose the ability to tell agent action from direct user action. For multi-agent systems or anything with compliance scrutiny, OBO Delegation is the safer default.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: obo-impersonation
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: agent-route
traffic:
jwtAuthentication:
mode: Strict
providers:
- issuer: "https://idp.example.com"
audiences: ["agent-gateway"]
jwks:
remote:
jwksPath: "/.well-known/jwks.json"
backendRef: { kind: Service, name: idp, namespace: idp-system, port: 443 }
backend:
tokenExchange:
mode: ExchangeOnly # impersonation: no actor; STS reissues with user sub onlyTrade-offs vs. Delegation: Simpler downstream model (single identity), but loses agent attribution.
Docs: OBO Token Exchange · About OBO & Elicitations API / Helm: tokenExchange Helm values
sequenceDiagram
autonumber
actor User
participant Agent
participant GW as Agent Gateway (Proxy)
participant STS as AGW Built-in STS
participant Tool as MCP Tool Server
User->>Agent: Request with user JWT
Agent->>GW: Authorization: Bearer <user JWT>
GW->>STS: POST /token (subject_token=user JWT, NO actor_token)
STS-->>GW: New token (sub=user, NO act claim)
GW->>Tool: Call with swapped token
Tool-->>GW: Response
GW-->>Agent: Response
Agent-->>User: Result
Where the work happens: same gateway-mediated flow as Delegation, but the gateway sends only
subject_tokento the STS — no actor token, nomay_actcheck. The STS mints a fresh JWT with the samesuband scopes, signed by AGW, and omits theactclaim. Downstream policies see only the user identity.
When to use: Your agent calls a third-party API (GitHub, Atlassian, Salesforce) on behalf of the user, and that API requires its own OAuth consent — separate from your gateway's IdP. The user authenticates twice: once for the gateway, once for the upstream API.
Two sequential user-facing OAuth flows orchestrated by the gateway:
- Inbound — user authenticates against the corporate IdP via OIDC (the gateway runs the redirect via
entExtAuth). - Outbound — when the gateway needs to call a third-party API, it triggers an elicitation. The user completes a second OAuth flow at the third-party (GitHub, Atlassian, etc.) via the Solo Enterprise UI. The STS stores the upstream token; subsequent requests reuse it.
Terminology note: The Solo docs use "downstream" (inbound, corporate SSO) and "upstream" (outbound, third-party). It's a common point of confusion — both flows are user-facing, but they happen at opposite ends of the gateway. The YAML below uses the docs' terminology, but think of them as inbound (user → gateway) and outbound (gateway → third-party).
Where this fits in Posta's framework. In MCP Authorization Patterns for Upstream API Calls, Christian Posta calls out five patterns for upstream API access. The pattern combining inbound OIDC at the gateway plus lazy outbound OAuth-via-elicitation is his recommended approach for organizations that want the agent flow to "just work" today — credentials never leak to MCP clients or servers, and the gateway is positioned to adopt emerging token-exchange standards.
apiVersion: v1
kind: Secret
metadata:
name: github-oauth-app
namespace: agentgateway-system
type: extauth.solo.io/oauth
stringData:
client-id: <github-app-client-id>
client-secret: <github-app-client-secret>
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: github-double-oauth
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: github-mcp
traffic:
entExtAuth: # 1) downstream OIDC (e.g., corporate SSO)
authConfigRef: { name: corporate-sso }
backendRef:
name: ext-auth-service-enterprise-agentgateway
namespace: agentgateway-system
port: 8083
backend:
tokenExchange: # 2) upstream OAuth via elicitation
# Elicitation is triggered by the presence of elicitation.secretName.
# Valid mode values are ExchangeOnly and ElicitationOnly.
elicitation:
clientName: github
secretName: github-oauth-appDocs: About OBO & Elicitations · Elicitations API: TokenExchangeMode
sequenceDiagram
actor User
participant GW as Agent Gateway (Proxy)
participant IdP as Enterprise IdP (downstream)
participant SoloUI as Solo Enterprise UI
participant Upstream as Upstream Provider (e.g. GitHub)
participant Agent
User->>GW: Request needing upstream API
GW-->>User: 302 to IdP /authorize (downstream OIDC)
User->>IdP: Login
IdP-->>GW: Downstream JWT
GW-->>User: PENDING + elicitation URL (no upstream token yet)
User->>SoloUI: Open elicitation URL
SoloUI->>Upstream: OAuth flow (user grants consent)
Upstream-->>SoloUI: Upstream token
SoloUI->>GW: Store token, mark COMPLETED
User->>GW: Retry original request
GW->>Agent: Forward + downstream JWT + injected upstream token
Agent-->>GW: Response
GW-->>User: Result
Available: GA from Solo Enterprise for agentgateway 2.3.x. The canonical reference deployment is
solo-io/mk-k8s-demos/mcp-auth(Helmfile + Helm chart). Earlier prototype shapes live atsolo-io/agentgateway-eager-oauth.
When to use: You expose multiple MCP servers backed by different third-party providers (GitHub, GitLab, Atlassian, Databricks…) and you want users to sign in to enterprise SSO once plus complete a one-time consent at each upstream provider — and then have no further OAuth prompts for the lifetime of those stored tokens.
Honesty about the user experience. The Solo marketing line is "sign in once and access everything." That's accurate for the corporate SSO half — login at the IdP happens once and is reused. But for each upstream provider (GitHub, GitLab, Atlassian, Databricks), a brand-new user still has to click through that provider's consent screen the first time. With four upstream backends, a first-time user sees four consent pop-ups in sequence at MCP-connect time. After that one-time gauntlet, the gateway persists the tokens and future sessions skip the pop-ups entirely.
Why this pattern exists, per Posta. In Connecting SaaS MCP Servers to Enterprise With Agentgateway, Christian Posta puts it bluntly: "SSO gives you user identity. What you need is user authorization: OAuth access tokens scoped to specific SaaS APIs on behalf of a specific user." Federated SSO alone is not enough — most SaaS providers want their own OAuth tokens, not just identity assertions from your IdP. Provider reality check: GitHub uses standard OAuth, Atlassian's Remote MCP requires federated credentials, Databricks offers some SSO-to-OAuth mapping, and "most other providers don't support it at all."
The gateway hosts its own OAuth Authorization Server at /oauth-issuer/. From an MCP client's perspective there is exactly one OAuth issuer: the gateway. Behind the scenes the gateway:
- Delegates the user's actual login to an enterprise IdP (Entra/Okta/Cognito) via
/oauth-issuer/callback/downstream— same Authorization Code flow as Variant A. - Pre-stages upstream OAuth credentials for each MCP backend at config time (static OAuth app for GitHub, Dynamic Client Registration for GitLab and Atlassian).
- Eagerly exchanges the downstream token into per-provider upstream tokens via
/oauth-issuer/callback/upstream. - Persists upstream tokens in PostgreSQL so they survive restarts and are reused across requests.
- Each MCP backend names the upstream secret it should use via
backend.tokenExchange.elicitation.secretName.
What does "Eager" mean here? The OAuth flow with each upstream provider runs at MCP connect time — when the client first establishes the MCP session — not lazily on the first tool call that needs the token. Once the session is up, every tool call inside it reuses the cached upstream tokens without further user interaction. Contrast with the lazy patterns Double OAuth Flow and Elicitation, where the user is prompted on demand whenever a request needs a fresh upstream token.
Single-IdP shape? If you don't need per-upstream-provider tokens — i.e. one IdP, JWT goes straight to the backend, and you just need gateway-brokered DCR because real DCR isn't viable on your IdP — see MCP OAuth — Gateway-Brokered DCR for Auth0 / Okta. It uses the same
/oauth-issuer/machinery without the per-backendbackend.tokenExchangepolicy or Variants 1/2 below.
| Double OAuth Flow | Elicitation | Eager Upstream OAuth | |
|---|---|---|---|
| OAuth issuer the MCP client sees | External IdP | External IdP + Solo UI | The gateway itself |
| When upstream consent is collected | First request that needs it | First request that needs it | At downstream login (or before) |
| Interactive flows the user sees | 2 (SSO + Solo UI) | 1 per provider, on demand | 1 (SSO only) |
| Upstream client registration | Per-provider in Solo UI | Per-provider in Solo UI | Static config or DCR |
| Token storage | Solo Enterprise control plane | Solo Enterprise control plane | PostgreSQL owned by the gateway |
sequenceDiagram
autonumber
actor User
participant MCP as MCP Client
participant GW as Agent Gateway (Proxy)<br/>+ /oauth-issuer
participant IdP as Enterprise IdP (Entra)
participant Upstream as Upstream Provider<br/>(GitHub / GitLab / Atlassian)
participant DB as Postgres
participant Tool as MCP Server
MCP->>GW: GET /.well-known/oauth-authorization-server/mcp/github
GW-->>MCP: Issuer = gateway, authorize URL = /oauth-issuer/authorize
MCP->>GW: /oauth-issuer/authorize (PKCE)
GW-->>MCP: 302 to IdP /authorize
User->>IdP: SSO login
IdP-->>GW: Callback /oauth-issuer/callback/downstream (code)
GW->>IdP: POST /token (code) → user JWT
GW-->>MCP: Issue gateway-issued auth code → access token
MCP->>GW: Call /mcp/github with gateway-issued token
GW->>DB: Look up upstream token for (user, github)
alt no upstream token yet
GW->>Upstream: OAuth code-flow (or DCR + code-flow) at /oauth-issuer/callback/upstream
Upstream-->>GW: Upstream access token
GW->>DB: Persist upstream token
end
GW->>Tool: Forward request with upstream provider token
Tool-->>GW: Response
GW-->>MCP: Response
tokenExchange:
enabled: true
issuer: "enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777"
tokenExpiration: 24h
subjectValidator:
validatorType: remote
remoteConfig:
url: "https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys"
database:
type: postgres
postgres:
url: postgres://myuser:mypassword@postgres.postgres:5432/mydb
controller:
extraEnv:
KGW_OAUTH_ISSUER_CONFIG: |
{
"gateway_config": {
"base_url": "https://gateway.example.com/oauth-issuer"
},
"client_config": {
"clients": { "<entra-client-id>": "<entra-client-secret>" }
},
"downstream_server": {
"name": "down",
"client_id": "<entra-client-id>",
"client_secret": "<entra-client-secret>",
"authorize_url": "https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token",
"redirect_uri": "https://gateway.example.com/oauth-issuer/callback/downstream",
"scopes": ["api://<entra-client-id>/agentgateway"]
}
}The proxy pod also needs STS_URI pointing at the elicitation token endpoint:
spec:
env:
- name: STS_URI
value: http://enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777/oauth2/token
- name: STS_AUTH_TOKEN
value: /var/run/secrets/xds-tokens/xds-tokenGitHub doesn't support DCR, so you pre-register an OAuth app there and ship its client_id / client_secret to the gateway:
apiVersion: v1
kind: Secret
metadata:
name: github-token-exchange
namespace: default
type: Opaque
stringData:
app_id: "github"
authorize_url: "https://github.com/login/oauth/authorize"
access_token_url: "https://github.com/login/oauth/access_token"
client_id: "<github-app-client-id>"
client_secret: "<github-app-client-secret>"
mcp_resource: "/mcp/github"
scopes: "repo read:org"
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: github-exchange
namespace: default
spec:
targetRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: github
backend:
tokenExchange:
mode: ElicitationOnly
elicitation:
secretName: github-token-exchangeGitLab and Atlassian support RFC 7591 DCR, so the gateway can register itself as an OAuth client at runtime — no static client_id/client_secret needed in the secret:
apiVersion: v1
kind: Secret
metadata:
name: gitlab-token-exchange
namespace: default
type: Opaque
stringData:
app_id: "gitlab"
base_url: "https://gitlab.com"
mcp_resource: "/mcp/gitlab"
scopes: "mcp"
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: gitlab-exchange
namespace: default
spec:
targetRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: gitlab
backend:
tokenExchange:
mode: ElicitationOnly
elicitation:
secretName: gitlab-token-exchangeapiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: github
spec:
mcp:
targets:
- name: mcp-target
static: { host: api.githubcopilot.com, port: 443 }
policies:
tls: {}
mcp:
authentication:
mode: Strict
issuer: https://sts.windows.net/<tenant>/
audiences: [api://<entra-client-id>]
jwks:
backendRef:
name: entra-jwks
kind: AgentgatewayBackend
group: agentgateway.dev
jwksPath: <tenant>/discovery/v2.0/keys
resourceMetadata:
# Tells MCP clients to find auth metadata at the gateway's own /oauth-issuer
agentgateway.dev/issuer-proxy: http://enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777/oauth-issuer
authorizationServers: [https://gateway.example.com/mcp/github]
resource: https://gateway.example.com/mcp/github
scopesSupported: [api://<entra-client-id>/agentgateway]- HTTPS is mandatory for non-localhost OAuth callbacks. The reference setup uses ngrok or cert-manager + Let's Encrypt.
- Token storage in Postgres survives restarts. SQLite in-memory is supported for dev only.
- DCR for GitLab can be done out-of-band first (the README shows a
curl https://gitlab.com/oauth/register); the runtime registration is what makes scaling to many providers tractable. - Single front door: MCP clients only need to know
https://gateway.example.com— they discover OAuth metadata via.well-known/oauth-authorization-server/<path>and never see the upstream providers' OAuth endpoints directly.
Source / repo: solo-io/mk-k8s-demos/mcp-auth (canonical Helmfile + chart) · solo-io/agentgateway-eager-oauth (earlier prototype) · Enterprise MCP SSO with Microsoft Entra and Agentgateway (blog)
Patterns for authenticating the gateway → backend hop.
When to use: The backend is in the same identity federation as the gateway (e.g., both trust the same IdP) and you want the user's original token to reach the backend unchanged.
Inbound auth (JWT or API key) validates the client and may strip the Authorization header. Passthrough re-attaches the validated token to the outbound request so the backend receives it as-is.
Anti-pattern warning for MCP / agent contexts. Christian Posta flags this in MCP Authorization Patterns for Upstream API Calls: the MCP spec explicitly calls token passthrough "an anti-pattern where an MCP server accepts tokens from an MCP client without validating proper issuance." The risk: if the backend doesn't independently validate the token (issuer, audience, signature), an attacker can replay a token from one service to another within the same trust domain.
Safe-use checklist for Passthrough:
- Backend and gateway share the same IdP / identity federation.
- Backend independently validates
iss,aud,exp, signature — not just "is there an Authorization header."- Tokens are short-lived (minutes, not hours).
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: backend-passthrough
namespace: agentgateway-system
spec:
targetRefs:
- group: ""
kind: Service
name: agents-backend
backend:
auth:
passthrough: {} # forward the original Authorization headerTrade-offs: Simplest backend-auth pattern when it fits. Couples the backend to the IdP — if you ever want to decouple, you'll need OBO or Token Exchange.
Docs: API Keys — Passthrough Token API: BackendAuthPassthrough (OSS) · BackendAuth (OSS)
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant Backend
Note over Client: Client already holds a token<br/>(JWT, opaque, etc.)
Client->>GW: Request + Authorization: Bearer <token>
GW->>GW: Inbound auth validates (JWT / API key) — strips header
GW->>GW: backend.auth.passthrough — re-attach original Authorization
GW->>Backend: Forward request (original Authorization preserved)
Backend-->>GW: Response
GW-->>Client: Response
When to use: The backend (e.g., OpenAI, Anthropic) accepts a single API key and you want the gateway to attach it to every outbound request. The user's identity is established at the gateway via a separate inbound auth policy.
Inbound auth validates the user. A separate backend.auth.secretRef policy injects a static credential into the outbound Authorization header. All users share the same upstream token. For per-user upstream tokens see Claim-Based Token Mapping or Elicitation.
Security framing. Christian Posta's API Keys Are a Bad Idea for Enterprise LLM, Agent, and MCP Access recommends "AI Gateway Architecture" as the pragmatic way to handle broad-permission upstream keys: "contain provider API keys in a single gateway that shields internal systems. Internal services use stronger authentication methods while the gateway handles upstream credentials." This pattern is exactly that — the gateway holds the shared credential, agents and users never see it, and the gateway becomes the centralized control point for rotation, audit, and rate limiting.
apiVersion: v1
kind: Secret
metadata:
name: github-auth
namespace: mcp-tools
type: Opaque
stringData:
Authorization: "Bearer ghp_..." # NOTE: must be stored under key "Authorization"
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: mcp-auth
namespace: mcp-tools
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agw-waypoint
backend:
auth:
secretRef:
name: github-authspec:
backend:
auth:
key: "Bearer sk-...redacted..." # least-secure variant; prefer secretRefDocs: API Keys — Manage API Keys API: BackendAuth (OSS)
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant Secret as K8s Secret (shared)
participant Backend as Upstream API (e.g. OpenAI)
Client->>GW: Request (authenticated as user via JWT/API key)
GW->>GW: Inbound auth validates user
GW->>Secret: Read shared upstream credential (secretRef)
Secret-->>GW: Authorization header value
GW->>Backend: Forward + Authorization: <shared token>
Backend-->>GW: Response
GW-->>Client: Response (all users share one upstream token)
When to use: Different users, teams, or tiers should call the backend with different upstream credentials (e.g., free vs. paid OpenAI keys; per-team Anthropic keys). You don't need user OAuth — a static map keyed by JWT claim is enough.
Validate the inbound JWT, then use a CEL request transformation to set the Authorization header based on a JWT claim (sub, team, tier, etc.).
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
name: claim-based-mapping
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: openai-route
traffic:
jwtAuthentication:
mode: Strict
providers:
- issuer: "https://idp.example.com"
audiences: [agent-gateway]
jwks:
remote:
jwksPath: "/.well-known/jwks.json"
backendRef: { kind: Service, name: idp, namespace: idp-system, port: 443 }
transformation:
request:
set:
# Pick a key based on the user's `tier` claim.
# `vault.lookup` here is a placeholder for whichever CEL function or
# static map your build exposes — substitute a `has(jwt.tier) && jwt.tier == "..."`
# ternary if you prefer pure CEL.
- name: Authorization
expression: |
"Bearer " + (
jwt.tier == "paid" ? "<paid-tier-key>" :
jwt.tier == "internal" ? "<internal-key>" :
"<free-tier-key>"
)Docs: CEL Transformations · JWT Auth for MCP Services API: TransformationPolicy (OSS) · EnterpriseAgentgatewayBackendPolicy
sequenceDiagram
actor Client
participant GW as Agent Gateway
participant Backend
Client->>GW: Request + Authorization: Bearer <user JWT>
GW->>GW: Validate JWT, extract claims (tier, team, sub)
GW->>GW: CEL transform sets Authorization based on jwt.tier<br/>(e.g. paid → key A, free → key B)
GW->>Backend: Forward with mapped Authorization header
Backend-->>GW: Response
GW-->>Client: Response (per-tier upstream credential)
When to use: The agent occasionally needs to call upstream APIs (GitHub, Atlassian, Google Workspace) on behalf of the user, and you need real user consent for each provider. Triggers a one-time per-user OAuth flow when needed; subsequent requests reuse the stored token.
When a request needs an upstream OAuth token but none is available yet, the gateway returns the elicitation URL to the client with a PENDING status. The user opens that URL in the Solo Enterprise UI to complete the upstream OAuth flow. Once COMPLETED, the client retries the original request and the gateway injects the stored token.
Posta's Pattern 4 — URL Elicitation. Christian Posta's MCP Authorization Patterns for Upstream API Calls calls this his Pattern 4 and one of the right answers for organizations starting today: it works, it keeps credentials away from MCP clients, and a recent MCP spec proposal formalizes the elicitation flow. Trade-off he flags: it "depends on user experience design and requires all agents in a chain to support it" — multi-hop agent setups need every hop to be elicitation-aware.
Async-flow story. When an autonomous agent runs without a user actively present, the elicitation flow can be delivered out-of-band — the gateway produces the elicitation URL and the user completes consent later via Slack, Teams, or email. The agent retries when the token arrives. That's the key difference between Elicitation and a synchronous OAuth redirect: it doesn't require the user to be on the call at the moment of consent.
Why Enterprise: Requires the Solo Enterprise UI to host the consent flow and the Enterprise control plane to durably store per-user upstream tokens. The
tokenExchange.elicitationfield is in the Enterprise proto only.
apiVersion: v1
kind: Secret
metadata:
name: jira-oauth-app
namespace: agentgateway-system
type: extauth.solo.io/oauth
stringData:
client-id: <jira-app-client-id>
client-secret: <jira-app-client-secret>
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: jira-elicit-only
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: jira-mcp
backend:
tokenExchange:
mode: ElicitationOnly # do not exchange, only gather upstream creds
elicitation:
clientName: jira
secretName: jira-oauth-appDocs: Elicitations · About OBO & Elicitations API: TokenExchangeMode
sequenceDiagram
actor Client
participant GW as Agent Gateway (Proxy)
participant STS as Token Exchange Server
participant SoloUI as Solo Enterprise UI
participant Upstream as External OAuth Provider
participant API as Upstream API
Client->>GW: Request (needs upstream OAuth token)
GW->>STS: Look up upstream token for this user
STS-->>GW: PENDING + elicitation URL
GW-->>Client: PENDING + elicitation URL
Client->>SoloUI: Open elicitation URL (browser)
SoloUI->>Upstream: Authorize (user consents)
Upstream-->>SoloUI: Authorization code → token
SoloUI->>STS: Complete elicitation, store token
Client->>GW: Retry original request
GW->>STS: Fetch stored token
STS-->>GW: Stored upstream token
GW->>API: Forward + inject upstream token
API-->>GW: Response
GW-->>Client: Result
Docs: Security Overview · OBO & Elicitations · External Auth · MCP Auth API: Enterprise API Reference
| Term | Meaning |
|---|---|
act claim |
An RFC 8693 JWT claim identifying the actor (the agent/service) acting on behalf of the subject. Paired with sub. |
| AuthConfig | Solo Enterprise CRD (extauth.solo.io/v1) describing an external auth flow (OIDC, OAuth2, API key, OPA, etc.) consumed by the Enterprise external auth service. Used by entExtAuth. |
| DCR | Dynamic Client Registration (RFC 7591). Lets OAuth clients register themselves with an authorization server at runtime instead of via human onboarding. See Background: DCR and MCP for why MCP needs it and where it works. |
| Eager OAuth | An OAuth flow that runs at MCP connect time — when the client first establishes the MCP session — rather than lazily on each tool call. Once the session is up, every tool call inside it reuses the cached token(s). Contrasts with Elicitation and Double OAuth Flow (lazy: prompt the user when a request needs a fresh token). |
| Gateway-Brokered DCR | A workaround for IdPs whose real DCR is impractical at scale (Auth0, Okta — DCR is gated behind their management APIs). The gateway intercepts the DCR call and returns a single pre-registered IdP client_id/client_secret to every MCP client, instead of forwarding to the IdP. See MCP OAuth — Gateway-Brokered DCR for Auth0 / Okta. |
entExtAuth |
Enterprise field on EnterpriseAgentgatewayPolicy.traffic that delegates auth to the Solo Enterprise external auth service via an AuthConfig. |
extAuth |
OSS field on AgentgatewayPolicy.traffic that delegates to a user-supplied gRPC or HTTP service via the Envoy ext_authz protocol. |
| Elicitation | An agentgateway flow that prompts a user to complete an upstream OAuth authorization out-of-band (in the Solo UI) so the gateway can inject the resulting token on later requests. Enterprise-only. |
| JWKS | JSON Web Key Set — the public keys an IdP publishes (typically at /.well-known/jwks.json) so receivers can verify JWT signatures. |
may_act claim |
A JWT claim a user issues authorizing a specific actor (agent) to call services on their behalf. Required by OBO Delegation. |
| MCP | Model Context Protocol — an open protocol for connecting LLM clients to tool-providing servers. |
| OBO | On-Behalf-Of. A pattern (typically via RFC 8693 or Microsoft's jwt-bearer grant) where one service exchanges a user token for a token scoped to a downstream resource. |
| OIDC | OpenID Connect. An authentication layer on top of OAuth 2.0 that produces an id_token (JWT) describing the user. |
| RFC 7617 | The HTTP Basic Authentication scheme. |
| RFC 8693 | The OAuth 2.0 Token Exchange spec — the standard for swapping one token for another. |
| STS | Security Token Service. The component that issues new tokens during exchange. agentgateway has a built-in STS for OBO flows; Entra ID can act as an external STS. Enterprise-only. |
sub claim |
The standard JWT claim identifying the subject (user or principal) the token is about. |
