MastertheMesh
Solo Enterprise for Istio · Reference
Visual reference

JWT, OIDC and on-behalf-of — auth flows in Istio Ambient

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

Four things people lump together: validating a JWT, writing rules against its claims, doing the OIDC login that produced the token, and exchanging a user token for a service token at a downstream hop. Istio Ambient handles the first two natively. The other two are gateway-side features — first-class in Solo Enterprise, ext-authz in OSS. This page is the map.

JWT OIDC RequestAuthentication token exchange Solo ext-authz

Auth is the topic where people most often run into trouble keeping the layers straight. This page maps every piece — JWT validation, claims-based authorization, OIDC login at the gateway, and RFC 8693 token exchange — to the component that actually runs it.

Five things to take away

01

JWT validation, authorization, and OIDC are three different things

Validation = is this token real? (signature, issuer). Authorization = does this token say I'm allowed to do this? (claims). OIDC = the login flow that produced the token in the first place. Different layers, different problems. Keep them separate in your head and most of the confusion goes away.

02

Validation + authorization are upstream Istio. OIDC + token exchange are Solo Enterprise.

OSS Istio gives you RequestAuthentication and AuthorizationPolicy — that's enough to validate JWTs and write rules on their claims. The OIDC login flow at the gateway and RFC 8693 token exchange are gateway-side features; in OSS you bolt them on with ext-authz, in Solo Enterprise they're first-class on agentgateway.

03

Token exchange = swap a user's token for a backend-scoped token

A user logs in via Okta or Entra and gets a JWT. The gateway can't just forward that JWT to a downstream backend like Snowflake — wrong audience, wrong trust domain. So the gateway calls a token-exchange service: "give me a new token, scoped for this backend, that still represents this user." That's RFC 8693. The pattern of preserving user identity through the swap is called OBO (on-behalf-of).

04

The runtime: agentgateway → Solo control-plane STS → IdP

The agentgateway data plane calls Solo's control-plane Security Token Service to perform the swap. The STS either signs the new token itself, or delegates the exchange to an external IdP — tested today with Entra for OBO. The gateway-to-STS exchange is RFC 8693 compliant, so any RFC 8693 compliant IdP should plug in.

05

Token-exchange config is global per agentgateway, not per-route

You can't have one route exchange against Okta and another route exchange against Entra in the same agentgateway. Two backends needing two IdPs = two agentgateway instances today. Note this is separate from OIDC login, which does work per-route via AuthConfig. Login and exchange are different problems.

Why anyone cares. An LLM agent calling an MCP server that fronts something sensitive — Snowflake, a customer database, a payment API — should call that backend as the originating user, not as a shared service account. Token exchange is what makes that possible. Eliminates the static-credentials anti-pattern, gives per-user audit attribution, lines up with DORA Article 28 and GDPR Article 30. This is the agentic identity story.

The whole flow on one canvas

CALLER USER · BROWSER · SPA · CLI EDGE · GATEWAY / AGENTGATEWAY MESH · WAYPOINT + BACKEND User / SPA / agent browser · CLI · LLM agent holds: cookie OR bearer JWT OIDC Provider Okta · Auth0 · Keycloak · Entra ID /authorize · /token · JWKS ① GET /api/x (no token) ② 302 to /authorize + PKCE challenge ③ redirect back + authorization code EDGE · Istio ingress gateway or agentgateway OIDC plug-in (login) PKCE · authorization-code exchanges code → id_token + access_token sets session cookie (Redis or signed) Solo: AuthConfig.oidcAuthorizationCode JWT validation RequestAuthentication verify signature against JWKS parse claims → request.auth.* OSS · Solo also: JwtPolicy Claims-based authorization AuthorizationPolicy when: request.auth.claims[groups] enforced at L7 (Envoy) — not ztunnel denies on mismatch → 403 Token-exchange endpoint (STS) RFC 8693 · /oauth/token subject_token (user JWT) → service-scoped JWT Solo Enterprise · agentgateway built-in STS MESH waypoint (L7) backend service ④ POST /oauth/token grant_type = …token-exchange subject_token = <user JWT> audience = backend ← returns service JWT ⑤ forward w/ service JWT (HBONE-wrapped mTLS) waypoint: optional 2nd RequestAuthentication OIDC discovery · /.well-known/openid-configuration (JWKS fetched by istiod — see diagram below) Token shape on each hop ①–③ Bearer <user JWT> · iss = corporate IdP · aud = app ④ POST to STS · subject_token = <user JWT> ⑤ Bearer <service JWT> · iss = STS · aud = backend
edge plugins (OIDC · JWT · claims authz) token-exchange STS · RFC 8693 waypoint + backend OIDC provider (Okta · Auth0 · Keycloak · Entra) caller / browser

How to read this: three operations happen at the edge — OIDC login (purple, left), JWT validation (purple, middle) and claims-based authorization (purple, right). All three run inside the same Envoy / agentgateway proxy, but they're separate concerns and separate CRs. The fourth piece — the amber STS box — only matters when the edge needs to call a downstream service with a different audience than the user's token. It accepts the user JWT and returns a new service-scoped JWT signed by the STS. The waypoint can then validate that fresh token with its own RequestAuthentication if you want belt-and-braces.

The CRDs, group by group

🪪 RequestAuthentication security.istio.io/v1

Declares which JWT issuers to trust and where to fetch their JWKS. The proxy verifies the signature, checks iss, exp and (if you list them) audiences, and on success parses claims into request.auth.principal, request.auth.audiences and request.auth.claims[...]. It does not deny anything by itself — an invalid token is rejected (401), a missing one is allowed through with empty claims. Pair it with an AuthorizationPolicy to actually require a token.

Often surprising: the proxy doesn't fetch the JWKS — istiod does, then ships it to every matching waypoint inline via xDS. The diagram below shows the default flow and the PILOT_JWT_ENABLE_REMOTE_JWKS=envoy alternative.

CONTROL PLANE ISSUER (outside cluster) DATA PLANE OIDC Provider / IdP Okta · Keycloak · Entra ID serves /.well-known/openid-configuration · JWKS istiod (control plane) JWKS resolver · xDS server caches JWKS in memory waypoint Envoy JwtAuthn filter (programmed by xDS) cached JWKS → verify signature check iss · aud · exp expose claims → request.auth.* no per-request call to the IdP client / SPA / agent holds: Authorization: Bearer ... istiod fetches JWKS every 20m · PILOT_JWT_PUB_KEY_REFRESH_INTERVAL xDS push JwtAuthn config + inline JWKS every waypoint fetches refresh = remoteJwks.cacheDuration xDS push JwtAuthn config only · no JWKS request · Bearer <JWT>
istiod (control plane) waypoint Envoy OIDC provider (outside cluster) client request Envoy-fetch mode (alt)

Default mode (istiod-fetch). istiod fetches the JWKS at jwksUri, caches it in memory, and pushes the JWKS inline with the JwtAuthn filter config via xDS to every waypoint that has a matching RequestAuthentication. The waypoint verifies signatures against the cached JWKS on each request — no IdP call per request, and only istiod needs egress to the IdP. Refresh runs on a 20-minute timer (PILOT_JWT_PUB_KEY_REFRESH_INTERVAL).

Envoy-fetch mode. With PILOT_JWT_ENABLE_REMOTE_JWKS=envoy (or true) on istiod, istiod stops fetching the JWKS itself. It ships only the RequestAuthentication config; each waypoint Envoy then fetches the JWKS directly from the IdP and refreshes on its own remoteJwks.cacheDuration. Every waypoint pod now needs egress to the IdP — a real consideration for airgapped clusters and NetworkPolicy. A hybrid value splits the two modes by issuer.

RequestAuthentication · validate JWTs from a single OIDC issuer at the waypointattaches to the waypoint Gateway via targetRef
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: payments-jwt
  namespace: payments-prod
spec:
  # Attach to the namespace waypoint (L7) — selector also works for
  # workload-scoped attachment.
  targetRefs:
  - kind: Gateway
    group: gateway.networking.k8s.io
    name: waypoint
  jwtRules:
  - issuer: "https://example.okta.com/oauth2/default"
    jwksUri: "https://example.okta.com/oauth2/default/v1/keys"
    audiences:
    - "api://payments"
    forwardOriginalToken: true     # leave the Authorization header intact upstream
    outputClaimToHeaders:
    - header: "x-user-email"
      claim: "email"

What each field actually does

targetRefs → Gateway
Attaches the policy to the waypoint Envoy — that's what makes JWT validation an L7 enforcement point. selector would land it on workload sidecars instead. The two are mutually exclusive: pick one.
jwksUri
Where the JWKS lives, but istiod is the one that fetches it (see diagram). The waypoint reads JWKS from its xDS-pushed config, not from this URL. Only one of jwksUri / jwks (inline) may be set.
audiences
Accepted aud claim values. A token whose aud isn't in this list fails validation and the principal is left empty — but the request still passes if no AuthorizationPolicy requires authentication.
forwardOriginalToken: true
Default is false — the proxy strips the Authorization header after validation. Set true when the upstream service needs to read the token itself (re-validate, parse claims, propagate).
outputClaimToHeaders
Copies a string/int/bool (or nested) claim into a request header for upstream consumption. Each header must be unique; pre-existing values are overwritten.

RequestAuthentication on its own never blocks anything. It validates tokens that are present (returning 401 on bad signature / wrong issuer / wrong audience) and ignores requests with no token at all. To actually require authentication, pair it with an AuthorizationPolicy rule whose from.source.requestPrincipals: ["*"] demands a valid JWT.

RequestAuthentication · multiple issuers on the same waypointOkta for humans, internal IdP for service-to-service
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: payments-jwt-multi
  namespace: payments-prod
spec:
  targetRefs:
  - kind: Gateway
    group: gateway.networking.k8s.io
    name: waypoint
  jwtRules:
  # End-user tokens, minted by the corporate Okta tenant
  - issuer: "https://example.okta.com/oauth2/default"
    jwksUri: "https://example.okta.com/oauth2/default/v1/keys"
    audiences: ["api://payments"]
  # Service tokens, minted by an internal IdP for east-west calls
  - issuer: "https://idp.internal/realms/services"
    jwksUri: "https://idp.internal/realms/services/protocol/openid-connect/certs"
    audiences: ["svc://payments"]
    fromHeaders:
    - name: "x-service-jwt"      # accept on a dedicated header, not Authorization

How Envoy picks the matching rule

multiple jwtRules
Envoy selects a rule by matching the inbound token's iss claim against the rule's issuer. A token whose iss doesn't match any listed issuer is treated as if no token was presented.
audiences per rule
Each issuer enforces its own audience. Cross-issuer audience confusion isn't possible because the rule is picked by iss first, then aud is checked against that rule's list only.
fromHeaders
Tells Envoy where to look for the token for this rule. The first rule (no fromHeaders) keeps the default Authorization: Bearer; the second rule reads x-service-jwt instead — so a single request can carry one of each without collision.
downstream matching by requestPrincipals
An AuthorizationPolicy can route on either issuer using the <iss>/<sub> format, e.g. requestPrincipals: ["https://idp.internal/realms/services/*"] for service tokens only.

One token at a time. If a single request carries tokens that match two rules (e.g. both Authorization and x-service-jwt populated with valid JWTs), Envoy's output principal is undefined. Have callers send exactly one.

Easy to miss: list jwtRules entries with distinct issuer values — Envoy picks the rule whose iss claim matches the inbound token. Field is enumerated in the upstream spec at istio.io/.../request_authentication.

🛡️ AuthorizationPolicy with claims security.istio.io/v1

L4 predicates (source.principals, namespaces, ports) are evaluated at ztunnel. Anything that reads from the request — paths, methods, headers, JWT claims — is L7 and only runs when a waypoint is in front of the destination. No waypoint, no claim-based authz. The upstream docs spell this out: HTTP-based attributes "cannot be processed" on TCP rules.

AuthorizationPolicy · allow only if groups claim contains payments-adminL7 — needs a waypoint
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payments-admin-only
  namespace: payments-prod
spec:
  targetRefs:
  - kind: Gateway
    group: gateway.networking.k8s.io
    name: waypoint
  action: ALLOW
  rules:
  - from:
    - source:
        requestPrincipals: ["*"]      # require *some* valid JWT
    when:
    - key: request.auth.claims[groups]
      values: ["payments-admin"]

What each predicate does — and how they combine

targetRefs → Gateway
Lands the policy on the waypoint, the only thing in Ambient that parses HTTP and surfaces request.auth.*. A selector-attached version on a workload with no waypoint would fail-closed and silently break the workload.
requestPrincipals: ["*"]
"Any valid JWT" — i.e. a token that produced a non-empty <iss>/<sub> principal. Token without a sub claim → no principal → no match.
request.auth.claims[groups]
Bracket syntax (no quotes) for the claim name. Chain brackets for nested claims ([a][b]). When the claim is an array, the rule matches if any array element appears in values.
action: ALLOW
Whitelist semantics. Once any ALLOW policy targets the waypoint, every request that doesn't match an ALLOW rule is denied — adding one ALLOW flips the default posture from allow to deny.

JWT claims surface only inside the waypoint. RequestAuthentication on the same waypoint must already have validated the token — without it, request.auth.claims is empty and nothing matches. A useful debug: istioctl proxy-config listener <waypoint-pod> -o json | jq '..|.name?' | grep -i jwt to confirm the JwtAuthn filter is programmed.

AuthorizationPolicy · deny if the aud claim isn't oursaudience mismatch → 403
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payments-aud-guard
  namespace: payments-prod
spec:
  targetRefs:
  - kind: Gateway
    group: gateway.networking.k8s.io
    name: waypoint
  action: DENY
  rules:
  - when:
    - key: request.auth.audiences
      notValues: ["api://payments"]

DENY semantics — read the footnote before shipping

action: DENY
Evaluated before ALLOW. If any DENY rule matches, the request is rejected with 403 regardless of any ALLOW policy on the same target.
notValues
"NOT-IN-LIST". The rule matches when none of the listed values appear in the attribute. At least one of values or notValues must be set per condition.
request.auth.audiences
The aud claim from a validated JWT, as a list. When there's no token (or no aud), the attribute is empty — and an empty list satisfies notValues, which matches the rule.

This rule over-denies anonymous requests. Because a missing aud matches notValues, anonymous (no-token) traffic hits this DENY too. If the intent is only "deny wrong-audience JWTs, let anonymous through," scope it with from.source.requestPrincipals: ["*"] so the DENY applies only to authenticated callers.

AuthorizationPolicy · mTLS identity AND claim-based scopeworkload identity at L4 · user scope at L7
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payments-write
  namespace: payments-prod
spec:
  targetRefs:
  - kind: Gateway
    group: gateway.networking.k8s.io
    name: waypoint
  action: ALLOW
  rules:
  - from:
    - source:
        # L4: only the checkout SA can hit /charge — enforced at ztunnel
        principals:
        - "spiffe://cluster.local/ns/checkout/sa/checkout"
    to:
    - operation:
        methods: ["POST"]
        paths: ["/charge"]
    when:
    # L7: the user JWT must carry the write scope — enforced at waypoint
    - key: request.auth.claims[scope]
      values: ["payments:write"]

How from + to + when compose

fields within one rule
All AND'd. Source identity, HTTP operation, and claim must all match for the rule to allow. Within a single field (e.g. multiple principals), entries are OR'd.
principals seen at the waypoint
When attached to a waypoint, the principal check matches the source workload's identity carried through HBONE — i.e. the original caller, not the waypoint itself. The spiffe:// scheme prefix is required; omitting it silently won't match.
methods + paths
L7. Only valid at the waypoint. The same policy attached via selector to a workload with no waypoint fails-closed: ztunnel sees L7 attributes it can't enforce and denies the request.
request.auth.claims[scope]
Requires a validated JWT — anonymous requests have no request.auth.*, so they can't match. Combined with ALLOW, this means: missing token → no match → denied by default.

Always attach principal-AND-claim policies at the waypoint. If you attach a SPIFFE-principal check at the destination ztunnel, it sees the waypoint's identity (not the original caller's) because the connection from waypoint → destination is a fresh HBONE leg. The waypoint is the only enforcement point that has both pieces of information at once.

Split brain to keep in mind: from.source.principals is workload identity (SPIFFE / mTLS) and gets enforced at ztunnel. when.request.auth.claims[...] is user identity from a JWT and only gets evaluated inside a waypoint. The trust & identity page covers the first one in depth.

🔁 OIDC + token exchange Solo Enterprise

Enterprise Upstream OSS Istio doesn't run an OIDC login flow at the gateway — you bolt one on with ext-authz, oauth2-proxy or a custom filter. Solo Enterprise / agentgateway ships it as a first-class AuthConfig with PKCE + session cookie support (Keycloak, Entra ID, Okta) and a built-in Security Token Service that does RFC 8693 token exchange. The YAML below is verbatim from the docs at docs.solo.io/agentgateway/.../oauth/authorization-code and .../obo/impersonation.

AuthConfig · OIDC authorization-code login on a Solo gateway, pointing at KeycloakPKCE redirect · session cookie in Redis
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: oauth-authorization-code
  namespace: agentgateway-system
spec:
  configs:
  - oauth2:
      oidcAuthorizationCode:
        appUrl: "http://${INGRESS_GW_ADDRESS}:80"
        callbackPath: /openai
        clientId: ${KEYCLOAK_CLIENT}
        clientSecretRef:
          name: oauth-keycloak
          namespace: agentgateway-system
        issuerUrl: "${KEYCLOAK_URL}/realms/master/"
        scopes:
        - email
        session:
          failOnFetchFailure: true
          redis:
            cookieName: keycloak-session
            options:
              host: :6379
        headers:
          idTokenHeader: jwt          # forward id_token as Authorization upstream
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: oauth-authorization-code
  namespace: agentgateway-system
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: agentgateway-proxy
  traffic:
    entExtAuth:
      authConfigRef:
        name: oauth-authorization-code
        namespace: agentgateway-system
      backendRef:
        name: ext-auth-service-enterprise-agentgateway
        namespace: agentgateway-system
        port: 8083

Why two resources — and what each field is for

AuthConfig vs EnterpriseAgentgatewayPolicy
Split-by-design. AuthConfig defines what the auth flow does (PKCE, Keycloak, Redis session). EnterpriseAgentgatewayPolicy attaches it where (this Gateway listener). One AuthConfig can be reused by many policies/listeners.
appUrl + callbackPath
The redirect URI Keycloak sees: ${appUrl}${callbackPath}. Must match the client's registered redirect URI in Keycloak exactly — most "redirect mismatch" 400s trace back to a trailing-slash difference.
session.failOnFetchFailure: true
If the gateway can't reach Redis on a request, the request fails closed — no anonymous fallthrough. Flip to false only if you've decided reduced security during a Redis outage is acceptable.
headers.idTokenHeader: jwt
After login, the gateway sets header jwt: <id_token> on the upstream request. This is what lets a downstream RequestAuthentication (or another extauth step) re-validate the user identity — without it, the upstream just sees a session cookie and has no token to verify.
backendRef · port 8083
Points at the ext-auth-service-enterprise-agentgateway Deployment shipped with the enterprise agentgateway chart. The Gateway delegates auth decisions to it over the ext-authz protocol.

Cookie collisions are silent. If you reuse session.redis.cookieName across two AuthConfigs (different IdPs / realms) on the same domain and cookie path, sessions overwrite each other and users get logged out of one when they log into the other. Give each AuthConfig a distinct cookieName.

EnterpriseAgentgatewayPolicy · RFC 8693 token exchange (impersonation OBO)swap user JWT for service JWT before the downstream call
# Impersonation: STS mints a new JWT preserving the user's `sub` claim
# but with a new issuer (the STS) and a new audience scoped to the
# downstream service. No `act` claim — the downstream sees the user.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: impersonation
  namespace: default
spec:
  targetRefs:
  - kind: AgentgatewayBackend
    group: agentgateway.dev
    name: mcp-backend
  backend:
    tokenExchange:
      mode: ExchangeOnly        # call STS, swap, forward — never inject the original token
---
# The actual exchange request the gateway makes (you don't write this
# yourself, but it's worth seeing). Matches RFC 8693.
#
#   POST /oauth/token
#   grant_type        = urn:ietf:params:oauth:grant-type:token-exchange
#   subject_token     = <user JWT from corporate IdP>
#   subject_token_type= urn:ietf:params:oauth:token-type:jwt
#   requested_token_type = urn:ietf:params:oauth:token-type:access_token
#   audience          = mcp-backend

Per-backend exchange — what's local vs what's global

targetRefsAgentgatewayBackend
Backend-scoped, not gateway-scoped. The same agentgateway can exchange tokens for one backend and pass the original token to another — useful when only some downstreams require service-scoped audiences.
tokenExchange.mode: ExchangeOnly
The standard impersonation flow. The STS validates the inbound user JWT, mints a new JWT with a new iss (the STS) and the backend's aud, and forwards only the new token. The original Authorization header is replaced, never both.
runtime dependency
This policy is the attachment point only — the STS itself must be enabled at install time via the Helm-level tokenExchange block (next example). Without that, the policy parses but has nothing to call.
impersonation vs delegation
Impersonation produces a token with the user as sub and no act claim — the downstream sees the user but cannot tell the call came via an agent. Delegation adds act. Pick by whether you need audit chain visibility downstream.

The STS is global per agentgateway. Even though mode is per-policy, the STS issuer URL, signing key, token lifetime, and subject/actor validators are all set once at Helm install — you cannot wire two backends to different upstream IdPs from this CRD alone. To support multiple IdPs you need multiple agentgateway instances.

tokenExchange Helm config · delegation mode (with act claim)audit trail: user → agent → tool
# Delegation differs from impersonation: the STS embeds both the user's
# `sub` *and* an `act` claim carrying the agent's identity. Downstream
# services see "the agent acting on behalf of the user" and can audit
# the full chain.

tokenExchange:
  enabled: true
  issuer: "enterprise-agentgateway.agentgateway-system.svc.cluster.local:7777"
  tokenExpiration: 24h
  subjectValidator:
    validatorType: remote
    remoteConfig:
      url: "${JWKS_URI}"            # user-token validation against corporate IdP
  actorValidator:
    validatorType: k8s              # agent identity = Kubernetes SA token

This is Helm values — not a CRD

issuer
The STS's own endpoint (internal cluster DNS, port 7777). Also becomes the iss claim on every minted token — downstream RequestAuthentication validating these tokens must point its jwksUri at this internal name.
tokenExpiration: 24h
Lifetime of the minted exchange token, not the user's original token. Longer → fewer STS round-trips, weaker revocation (no way to invalidate before expiry).
subjectValidator: remote
Validates the inbound user JWT's signature against an external IdP's JWKS at remoteConfig.url. Switch to validatorType: k8s if the inbound subject is itself a K8s ServiceAccount token (validated via TokenReview).
actorValidator: k8s
Validates the agent's identity via Kubernetes TokenReview on the agent's mounted SA token. The combination "subject = remote OIDC user, actor = k8s SA" is the standard human-via-agent pattern.

Delegation vs impersonation, the one-line version: delegation's minted token carries an act claim with the agent's identity alongside the user's sub; impersonation omits act and the downstream sees only the user. Delegation preserves the audit chain ("the MCP agent ran the query on behalf of Alice"); impersonation hides the agent ("Alice ran the query"). Both produce per-user, per-request tokens — neither is the shared-service-token anti-pattern.

What's in the swapped token — and what NOT to do

Both OBO modes produce a per-user, per-request token — sub is always the user. The difference is whether the agent's identity is also carried via act. The third card is the shape OBO is designed to replace: one shared service-account token for every call, with user identity lost (or relegated to a header). Don't ship that.

Impersonation — sub only
{
  "iss": "solo-sts",
  "sub": "alice@corp.com",
  "aud": "snowflake-mcp",
  "exp": 1700003600
}
Per-user, per-request token. Backend sees Alice; agent is invisible to the backend. Audit trail: "Alice ran the query."
Delegation — sub + act
{
  "iss": "solo-sts",
  "sub": "alice@corp.com",
  "act": {
    "sub": "spiffe://.../mcp-agent"
  },
  "aud": "snowflake-mcp",
  "exp": 1700003600
}
Per-user, per-request token. Backend sees both — the agent acting on behalf of Alice. Audit trail: "The MCP agent ran the query on behalf of Alice."
Shared service token — every user, same token
{
  "iss": "solo-sts",
  "sub": "agentgateway-sa",
  "aud": "snowflake-mcp",
  "exp": 1700003600
}

// + header: x-on-behalf-of: alice@corp.com
// (or worse — no user context at all)
One token reused for every caller. Backend sees the gateway, not the user. Audit trail collapses to "the gateway ran the query" — Alice's name lives in a header the backend has to trust without verifying. This is the static-credential anti-pattern OBO replaces.

Note on naming: Solo Enterprise also exposes JwtPolicy (security.policy.gloo.solo.io/v2) and ExtAuthPolicy for non-agentgateway gateway use cases — same building blocks, different attachment surface. The upstream Istio RequestAuthentication still works on a waypoint; the Solo CRs add policy attachment, multi-tenant routing and the STS integration. Confirm the exact CR for your topology against docs.solo.io/.../jwt_policy and .../ext_auth_policy.

Where token exchange lives in agentgateway today

Why a swap is needed at all

A user's JWT was issued for the user's own app to call. It's not valid for a downstream backend like a Snowflake MCP server — different audience, different issuer, often different scopes too. The gateway cannot just forward it.

User JWT from Okta
{
  "iss": "https://corp.okta.com",
  "sub": "alice@corp.com",
  "aud": "my-spa-app",
  "scope": "openid email",
  "exp": 1700000000
}
What the user presents at the gateway.
aud wrong
iss wrong
cannot forward
What the backend will accept
{
  "iss": "https://solo-sts.internal",
  "sub": "alice@corp.com",
  "aud": "snowflake-mcp",
  "scope": "snowflake.query.read",
  "exp": 1700000000
}
What the backend trusts. Same user, different token.

How the runtime is wired

The OBO YAML above is the protocol surface. The runtime is worth pinning down separately. The agentgateway data plane does not call an external IdP directly. It calls Solo's control-plane Security Token Service, which either signs the new token itself or delegates the exchange to an external IdP.

The path the data plane takes

     [ User token from corporate IdP (Okta / Entra / Keycloak) ]
                             │
                             ▼
                     ┌───────────────┐
                     │ agentgateway  │   ◄── data plane, takes the request
                     │  (data plane) │
                     └───────┬───────┘
                             │ RFC 8693 token-exchange call
                             ▼
                     ┌───────────────┐
                     │ Solo Control  │   ◄── signs the new token itself
                     │ Plane (STS)   │       OR delegates to an external IdP
                     └───────┬───────┘
                             │ delegation (tested today with Entra OBO)
                             ▼
                       ┌──────────┐
                       │  IdP     │
                       └──────────┘
Compatibility. The agentgateway-to-STS exchange is RFC 8693 compliant. Delegation to Entra OBO is tested today; any other RFC 8693 compliant IdP should plug in via the same path.
Per-route vs global config. The token-exchange endpoint is configured globally per agentgateway instance, not per-route. You cannot have one route exchange against Okta and another route exchange against Entra in the same agentgateway today. If you need multiple external IdPs for different downstream targets, that's either two agentgateway instances or a feature ask on the product team. This is separate from per-route OIDC login (which works today via AuthConfig attached per route).

Why this matters — agentic credential brokering

The use case driving this is what makes agentgateway worth picking over a generic API gateway. An LLM agent calling an MCP server that fronts something sensitive — a Snowflake query, a customer database, a payment API — should call that backend as the originating user, not as a shared service principal.

OSS Istio vs Solo Enterprise — what's in the box

The split between "you can do this with upstream Istio" and "you'd reach for Solo Enterprise" is where most architectural conversations end up. This is what I've been able to pin down from the docs.

Capability Upstream OSS Istio Solo Enterprise
JWT validation at waypoint / gateway Yes · RequestAuthentication Yes · upstream CR + JwtPolicy (security.policy.gloo.solo.io/v2)
JWKS auto-refresh Yes · istiod fetches + pushes inline via xDS by default (refresh PILOT_JWT_PUB_KEY_REFRESH_INTERVAL, default 20m). Flip to Envoy-fetch with PILOT_JWT_ENABLE_REMOTE_JWKS=envoy Yes · cacheDuration field on remote JWKS
Multiple issuers per endpoint Yes · list of jwtRules Yes · map of providers on JwtPolicy
Claims as AuthorizationPolicy predicates Yes · when.request.auth.claims[…] at L7 Yes · same upstream surface, plus claims matcher on JwtPolicy
OIDC login flow at the gateway (PKCE redirect, session cookie) No — bolt on ext-authz / oauth2-proxy Yes · AuthConfig.oauth2.oidcAuthorizationCode
External auth integration (ext-authz) Yes · raw Envoy ext-authz API Yes · ExtAuthPolicy + managed ext-auth-service
RFC 8693 OAuth token exchange / OBO No Yes · built-in STS on agentgateway, impersonation + delegation modes
External IdP support for OBO No Yes · tested with Entra OBO; the gateway-to-STS exchange is RFC 8693 compliant, so any RFC 8693 IdP should plug in
Per-route token-exchange endpoint No No — token-exchange config is global per agentgateway instance; multiple external IdPs need multiple instances
Multi-tenant per-route OIDC login Partial — one ext-authz cluster per route is doable but manual Yes · AuthConfig attached via per-route policy
Session storage (Redis / cookie) No Yes · session.redis and signed-cookie modes
JWT claim manipulation (copy / output to header) Yes · outputClaimToHeaders, outputPayloadToHeader Yes · claimsToHeaders on JwtPolicy
Per-route audience enforcement Yes · audiences on each jwtRule Yes · same upstream field + per-policy attachment
OBO with full audit chain (sub + act claim) No Yes · delegation mode on agentgateway STS

Sources fetched: upstream Istio request_authentication and authorization-policy references; Solo jwt_policy and ext_auth_policy API; agentgateway token-exchange, OBO impersonation, delegation, OIDC authorization-code and JWT setup. Rows marked Partial are where the docs index doesn't speak directly to multi-tenant per-route OIDC in OSS — verify with the Solo team if you're sizing a real deployment against it.

CLI — inspect tokens at runtime

🔬 What to run when authz misbehaves debug

Most JWT bugs are unsurprising once you've actually read the token, diffed its iss against what RequestAuthentication expects, and checked the JWKS Envoy is using. These are the commands worth in shell history.

kubectl get · what auth resources exist wherefirst thing to run
# Every namespace, both kinds
kubectl get requestauthentication,authorizationpolicy -A

# And the Solo CRs if you're using Gloo Mesh Enterprise / agentgateway
kubectl get jwtpolicy,extauthpolicy,authconfig -A
kubectl describe requestauthentication · see the JWKS URI Envoy will hitspot stale issuer URLs
kubectl -n payments-prod describe requestauthentication payments-jwt

# Or just the issuer/jwksUri pair, no scrolling:
kubectl -n payments-prod get requestauthentication payments-jwt \
  -o jsonpath='{range .spec.jwtRules[*]}{.issuer}{"\t"}{.jwksUri}{"\n"}{end}'
istioctl proxy-config · confirm the JwtAuthn filter is configured on the waypointgrep is your friend
# Find the waypoint pod
kubectl -n payments-prod get pods -l istio.io/gateway-name=waypoint

# Dump all listener / route / cluster config for that pod
istioctl proxy-config all payments-prod/waypoint-7b6c... | less

# The JWT filter shows up under the HTTP filter chain — grep for it
istioctl proxy-config listener payments-prod/waypoint-7b6c... -o json \
  | jq '.. | .name? // empty' | grep -i jwt
Decode a JWT with jq + base64 · no tooling requiredone-liner
# Paste the token into $TOKEN. Header is segment 1, payload is segment 2.
TOKEN='eyJhbGciOi...'

# Header (alg, kid)
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .

# Payload (iss, sub, aud, exp, claims)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .

# Just the bits that decide whether validation will pass:
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null \
  | jq '{iss, aud, exp, scope, groups: .groups}'
curl · verify the 401 → 200 transition after fresh loginthe smoke test
GW=https://api.example.com

# 1. No token — expect 401 (if AuthorizationPolicy requires a JWT)
curl -i $GW/api/payments

# 2. Expired token — expect 401, with a www-authenticate header
curl -i -H "Authorization: Bearer $OLD_TOKEN" $GW/api/payments

# 3. Fresh token — expect 200
TOKEN=$(curl -s -X POST "$IDP/oauth/token" \
  -d grant_type=client_credentials \
  -d client_id=$CID -d client_secret=$CSEC \
  -d audience=api://payments | jq -r .access_token)
curl -i -H "Authorization: Bearer $TOKEN" $GW/api/payments

# 4. Same token, wrong audience claim → 403 from your AuthorizationPolicy

Full reference table

The resources you'll touch on this surface, with API group, what they do, and where in the data path they're actually evaluated.

Resource Source API What it does Where it runs
RequestAuthentication OSS security.istio.io/v1 Validates a JWT's signature, issuer and audience; parses claims into request.auth.*. Doesn't deny — pair with AuthorizationPolicy. waypoint · ingress gateway
AuthorizationPolicy OSS security.istio.io/v1 ALLOW/DENY rules. from.source.principals is L4 (mTLS); when.request.auth.claims[…] is L7 (JWT). ztunnel (L4) · waypoint (L7)
JwtPolicy Solo security.policy.gloo.solo.io/v2 Solo's per-route JWT enforcement with multi-provider map, claim matchers, remote JWKS caching, claims-to-headers. gateway · waypoint (via attachment)
ExtAuthPolicy Solo security.policy.gloo.solo.io/v2 Attach ext-auth (OIDC, API key, OPA, LDAP, basic) to routes or destinations. References an AuthConfig for the actual recipe. gateway · ext-auth-service
AuthConfig Solo extauth.solo.io/v1 The recipe — OIDC fields (issuerUrl, clientId, callbackPath, scopes, session), API-key store, OPA module. ext-auth-service
EnterpriseAgentgatewayPolicy Solo enterpriseagentgateway.solo.io/v1alpha1 Attaches JWT, OIDC and token-exchange (RFC 8693) config to an agentgateway route or backend. agentgateway proxy
Token-exchange STS Solo /oauth/token · /.well-known/jwks.json RFC 8693 Security Token Service. Accepts subject_token (user JWT) and optionally actor_token; returns a service-scoped JWT. agentgateway (built-in)
The JWT itself N/A RFC 7519 Not a CRD — but it's the thing every CR above is reasoning about. Header (alg, kid) + payload (iss, sub, aud, exp, custom claims) + signature. Authorization header · cookie · query param

Where to go from here

This page covered user identity — JWTs minted by an OIDC provider, validated and reasoned about at the edge. The other half of identity in a mesh is workload identity — SPIFFE IDs in SVIDs, used for mTLS between ztunnels. Two different stories, one runtime. See the trust & identity walk-through for that side. For where these CRs attach in the data path, Gateway API in Ambient covers the routing surface.

Upstream references: Istio JWT routing task, RequestAuthentication reference, AuthorizationPolicy reference. Solo references: Gloo Mesh Enterprise JWT, agentgateway token exchange, agentgateway OBO.