The pattern shows up in most multi-tenant gateway setups that use
JWT / OIDC: the IdP issues a JWT with a tenantId claim,
the gateway verifies the signature, and downstream config keys off
the tenant as a plain header. The body of this page walks through it
on kgateway. The section at the end shows the equivalent on
agentgateway. Different data plane (Rust, ztunnel lineage, not
Envoy), independent JWT and JWKS implementation, so reason strings
are not guaranteed to line up. The CRD shape and the claims-to-headers
step are what change.
What you'll learn
How verified JWT claims become request headers on both gateways, and why routing, rate limiting and your app code can trust them:
-
How kgateway's
claimsToHeaders(underentJWT/AuthConfig) lifts a verified claim into a header, and the two CRDs that wire it. -
The agentgateway equivalent, a
transformationblock with a CEL expression readingjwt.<claim>, plus when to reach for each gateway. -
Why a client cannot smuggle its own
x-tenant-idpast the gate, and how to strip inbound copies at the listener. - The negative tests (missing, tampered, expired, wrong audience, wrong issuer) that prove the gateway rejects bad tokens before the backend.
1 · The JWT payload
A JWT is a signed envelope around a JSON payload — the claims. The IdP signs it with a private key; the matching public key is published on a JWKS (JSON Web Key Set) endpoint that anyone can fetch. A typical multi-tenant access token payload looks like this:
Payload Decoded JWT claims
Issuer + audience identify who minted the token and
who it's for. sub identifies the user.
tenantId (or whatever the IdP names it) identifies
the customer org the user belongs to — that's the claim we'll
route and rate-limit on.
{
"iss": "https://idp.example.com", // issuer — used to look up the JWKS
"aud": "api.example.com", // audience — your API
"sub": "user_abc123", // user id
"tenantId": "tnt_acme", // which customer org
"scope": "orders:read orders:write",
"iat": 1731596400,
"exp": 1731600000
}
2 · The request flow
How to read it. The request arrives with
Authorization: Bearer <jwt>. kgateway verifies the
signature using public keys fetched from the IdP's JWKS endpoint
(cached 300s so a key rotation lands within five
minutes without restarting anything). Tampered or unsigned tokens
get a 401 right here. After verification, claimsToHeaders
copies the named claims out of the verified payload into request
headers, and kgateway forwards to the backend.
The bearer token does not need to reach the backend — the app
trusts the headers because kgateway only writes them post-verification.
3 · The four-step flow, in words
Step 1 Request arrives
The client sends a normal HTTPS request with
Authorization: Bearer <jwt>. kgateway terminates
TLS at the listener.
Step 2 Verify signature against the IdP's JWKS
kgateway fetches the public signing keys from
remoteJwks.url (cached for
cacheDuration: 300s), checks the JWT signature, and
validates exp / iss / aud.
Tampered, expired, or unsigned tokens get a 401 at
this step — they never reach the backend.
Step 3 claimsToHeaders — lift verified claims into headers
Once the signature is valid, kgateway copies named claims out of the verified payload into request headers, in order:
claimsToHeaders:
- { claim: tenantId, header: x-tenant-id }
- { claim: sub, header: x-user-id }
Result on the wire heading downstream:
GET /api/v1/orders HTTP/1.1
x-tenant-id: tnt_acme
x-user-id: user_abc123
Step 4 Forward to the backend
kgateway forwards the request. The backend sees the verified-claim headers and can trust them — they only exist because the gateway wrote them after a successful signature check. The app never has to crack open a JWT itself, and the bearer token can be stripped before the hop.
4 · The two CRDs that wire it
Two CRDs do this on kgateway enterprise. AuthConfig
defines the JWT validation + claimsToHeaders behaviour.
EnterpriseKgatewayTrafficPolicy attaches that
AuthConfig to a Gateway (or HTTPRoute, or backend) so it
actually runs on traffic.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
name: example-idp
namespace: kgateway-system
spec:
configs:
- oauth2:
accessTokenValidation:
jwt:
remoteJwks:
url: https://idp.example.com/.well-known/jwks.json
cacheDuration: 300s
claimsToHeaders:
- { claim: tenantId, header: x-tenant-id }
- { claim: sub, header: x-user-id }
---
apiVersion: enterprisekgateway.solo.io/v1alpha1
kind: EnterpriseKgatewayTrafficPolicy
metadata:
name: example-idp
namespace: kgateway-system
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: http }
entExtAuth:
authConfigRef:
name: example-idp
namespace: kgateway-system
| Field | What it does |
|---|---|
remoteJwks.url |
Where kgateway fetches the IdP's public signing keys to verify the JWT signature. |
cacheDuration |
How long kgateway caches the JWKS response. 300s means a key rotation propagates within 5 minutes — no restart. |
claimsToHeaders[] |
Maps a named claim in the verified payload to a request header on the forwarded request. Runs only after the signature check passes. |
targetRefs |
Which Gateway / HTTPRoute / backend this policy applies to. Standard Gateway API attachment. |
entExtAuth.authConfigRef |
Points the TrafficPolicy at the AuthConfig to enforce on that target. |
4b · Concrete example — Frontegg as the IdP
Frontegg is a worked-through example of the generic pattern above.
It issues standard OIDC tokens — JWTs signed by an RS256 key
published on a JWKS endpoint at
https://<tenant>.frontegg.com/.well-known/jwks.json —
so no Frontegg-specific integration is needed. The tenant claim
Frontegg emits is named tenantId, which maps cleanly onto
x-tenant-id via claimsToHeaders.
Frontegg AuthConfig + EnterpriseKgatewayTrafficPolicy
Same two-CRD shape as section 4, just with Frontegg's JWKS URL
pinned in. The Frontegg tenant subdomain goes in the
remoteJwks.url; everything downstream
(HTTPRoute matches, rate-limit descriptors, app code)
continues to key off x-tenant-id and
x-user-id without knowing which IdP minted the token.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
name: frontegg
namespace: kgateway-system
spec:
configs:
- oauth2:
accessTokenValidation:
jwt:
remoteJwks:
url: https://<tenant>.frontegg.com/.well-known/jwks.json
cacheDuration: 300s
claimsToHeaders:
- { claim: tenantId, header: x-tenant-id }
- { claim: sub, header: x-user-id }
---
apiVersion: enterprisekgateway.solo.io/v1alpha1
kind: EnterpriseKgatewayTrafficPolicy
metadata:
name: frontegg
namespace: kgateway-system
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: http }
entExtAuth:
authConfigRef:
name: frontegg
namespace: kgateway-system
Things to confirm with Frontegg specifically: the
exact tenant-claim name (sometimes tenantId, sometimes
namespaced like https://frontegg.com/tenantId depending
on app config), the aud value the IdP puts on tokens
for this API, and whether you're using Frontegg's per-tenant JWKS
subdomain or a single tenancy JWKS — the cache-duration trade-off
changes with each.
5 · Why getting the claim into a header up front matters
Two big things downstream of the gateway key off
x-tenant-id — and neither of them needs to know that
it came from a JWT.
HTTPRoute match can route
on x-tenant-id the same way it routes on
:path. Per-tenant canaries, per-tenant header rewrites,
per-tenant backends — all become regular Gateway API config.
tenant-id outer,
:path inner — keyed off the
x-tenant-id header. The whole multi-tenant rate-limit
story rests on the claim already being present as a header by the
time the rate-limit filter runs.
x-tenant-id from the request, full stop. No JWT
library, no signature check, no JWKS cache in the app. The trust
boundary is the gateway — and because the gateway only writes the
header after a successful signature check, the header is
trustworthy.
That last point is the load-bearing one. The header is
trustworthy because the gateway only writes it
post-verification — if the JWT failed signature check at
step 2, the request returned 401 and the
claimsToHeaders mapping never ran. The
append field on each claimsToHeaders entry
defaults to false, meaning the gateway
overwrites any incoming header of the same name with the
verified value — so a client can't smuggle their own
x-tenant-id past the gate just by sending the header
on the request. (Belt-and-braces options for stripping it earlier
in the filter chain are in §6.)
6 · Common pitfalls
Pitfall The claim name is whatever the IdP says it is
RFC 7519
defines a handful of registered claims —
iss / sub / aud /
exp / nbf / iat /
jti — and the
OIDC Core 1.0
spec extends that with a fixed set of identity claims for ID tokens
(email, preferred_username,
name, etc.). Tenant identity is not in either
spec — every IdP names and shapes it differently. Always decode a
real token from the IdP (jwt.io, step crypto jwt
inspect, or jq) and confirm the exact claim
name and value shape before pinning it in
claimsToHeaders:
| IdP | Where the tenant lives | claimsToHeaders entry |
|---|---|---|
| Frontegg | tenantId — top-level string. Sometimes namespaced (https://frontegg.com/tenantId) depending on app config. |
{ claim: tenantId, header: x-tenant-id } |
| Auth0 | Custom claim added via an Action or Rule. Auth0 requires a namespace URI on non-OIDC claims, e.g. https://yourapp.example.com/tenant_id. |
{ claim: "https://yourapp.example.com/tenant_id", header: x-tenant-id } |
| Azure / Entra ID | tid — the directory (Azure AD tenant) GUID. For an app-level tenant inside one directory, add an optional claim or app role (e.g. extn.tenant). |
{ claim: tid, header: x-tenant-id } |
| Okta | Configured per authorization server as a custom claim (e.g. tenant or org_id) sourced from a user profile attribute or expression. |
{ claim: tenant, header: x-tenant-id } |
| Keycloak | Defined via a Protocol Mapper on the client (User Attribute mapper or Hardcoded Claim). Mapper name sets the claim — commonly tenant_id or organization. |
{ claim: tenant_id, header: x-tenant-id } |
Pitfall Don't trust client-supplied x-tenant-id
A client sending x-tenant-id: tnt_competitor on a
request must not influence routing or authentication. kgateway has
two documented mechanisms here — use both:
1 · Overwrite via claimsToHeaders default.
Each claimsToHeaders entry has an append
field that defaults to false. Per
Solo's
field reference: "If false (default), overwrite
the header. Do not set to true on public-facing
gateways as it trusts incoming headers."
So as long as you leave append unset (or
false), the verified-claim value clobbers anything the
client sent.
2 · Strip before route selection and ext-auth with
ListenerPolicy.earlyRequestHeaderModifier.
This is kgateway's documented defence-in-depth pattern — the headers
get removed at the listener, before route matching or
authentication run, so they cannot influence either:
apiVersion: gateway.kgateway.dev/v1alpha1
kind: ListenerPolicy
metadata:
name: strip-claim-headers
namespace: kgateway-system
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: http }
default:
httpSettings:
earlyRequestHeaderModifier:
remove:
- x-tenant-id
- x-user-id
Either alone is sufficient for the smuggling case — the
ListenerPolicy is the cleaner of the two because the
header is gone by the time any other filter looks at it, and the
intent is explicit in YAML rather than relying on a default. See
Solo's
Early Request Header Modification
docs for the full filter-chain ordering.
Pitfall JWKS fetch is a hard dependency
If the IdP's JWKS endpoint is unreachable when kgateway boots and
has no cached key, signature validation fails closed and every
request gets a 401. cacheDuration: 300s gives you a
five-minute survival window after a successful fetch — set it
deliberately based on how often the IdP rotates keys and how long
a JWKS outage you're willing to ride out.
7 · Negative tests
The point of validating a JWT at the gateway is that the wrong
tokens never reach the backend. The cases below are the ones worth
running explicitly when you stand this up, so you can see the
gateway's rejection and confirm it is the gateway doing the work,
not the app. Every case references a shell variable
($VALID_JWT, $EXPIRED_JWT, etc.) — those
come from the shared mint script.
7.0 Prereq — mint the test tokens
Run the shared
Mint your own test JWTs
helper once, then source tokens.env. The helper
generates a fresh RS256 keypair, writes the matching
jwks.json the gateway will fetch, and exports one
shell variable per variant referenced below.
This lab uses six of the variants the helper produces. Set the
kgateway AuthConfig's
jwt.providers.<name>.issuer to
https://test-idp.example.com and
audiences to ["api.example.com"] to
match what the helper signs.
Case 1 Missing token
No Authorization header at all. JWT validation
refuses the request because there is nothing to verify.
$ curl -is https://gw.example.com/api/v1/orders | head -3
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http"
content-length: 14
Jwt is missing
Access log: response_code=401,
response_code_details=jwt_authn_access_denied.
Case 2 Tampered signature
Use $TAMPERED_JWT from §7.0 — a valid token with
one byte flipped in the signature segment. The JWKS key loads
correctly; the signature no longer matches the header + payload
digest, so verification fails.
$ curl -is -H "Authorization: Bearer $TAMPERED_JWT" \
https://gw.example.com/api/v1/orders | head -4
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http"
content-length: 23
Jwt verification fails
The bytes never reach claimsToHeaders, so
x-tenant-id is never written and the backend never
sees the request.
Case 3 Expired token
Use $EXPIRED_JWT from §7.0 — same key, same
audience, but exp is one hour in the past.
Signature verifies; the time check rejects.
$ curl -is -H "Authorization: Bearer $EXPIRED_JWT" \
https://gw.example.com/api/v1/orders | head -4
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http"
content-length: 14
Jwt is expired
Case 4 Wrong audience
$WRONG_AUD_JWT carries
aud=api.other.com, which is not in the
audiences list on the AuthConfig.
Signature and time are both valid; the audience check still
rejects.
$ curl -is -H "Authorization: Bearer $WRONG_AUD_JWT" \
https://gw.example.com/api/v1/orders | head -4
HTTP/1.1 403 Forbidden
www-authenticate: Bearer realm="http", error="invalid_token"
content-length: 32
Audiences in Jwt are not allowed
The simplest defence against token replay across APIs. If the
aud on the token does not name this gateway's
audience, the gateway rejects it before claim mapping or routing
happens.
Case 5 Wrong issuer
$WRONG_ISS_JWT claims
iss=https://other-idp.example.com, which has no
entry in the AuthConfig. The gateway has no JWKS
configured for that issuer so the token cannot be verified.
$ curl -is -H "Authorization: Bearer $WRONG_ISS_JWT" \
https://gw.example.com/api/v1/orders | head -4
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http"
content-length: 28
Jwt issuer is not configured
Case 6 Client tries to smuggle x-tenant-id
The client sends $VALID_JWT (which has
tenantId=tnt_acme) and adds an
x-tenant-id: tnt_competitor header on the same
request, hoping to swap tenants after the auth check. With the
defaults described in section 6 (or a ListenerPolicy
stripping the header at the listener), the smuggled value never
reaches routing or the backend.
$ curl -is \
-H "Authorization: Bearer $VALID_JWT" \
-H "x-tenant-id: tnt_competitor" \
https://gw.example.com/headers
HTTP/1.1 200 OK
content-type: application/json
{
"headers": {
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs...",
"X-Tenant-Id": "tnt_acme",
"X-User-Id": "user_abc123"
}
}
The proof is the X-Tenant-Id value at the backend.
Use httpbin's /headers endpoint (or any
echo backend) so you can read exactly what the upstream saw.
tnt_competitor never appears.
Case 7 Token is valid but missing the tenant claim
$NO_TENANT_JWT is signed correctly and the audience
matches, but the IdP omitted tenantId. JWT
validation passes; claimsToHeaders has nothing to
copy for that mapping.
$ curl -is -H "Authorization: Bearer $NO_TENANT_JWT" \
https://gw.example.com/headers
HTTP/1.1 200 OK
content-type: application/json
{
"headers": {
"Authorization": "Bearer eyJhbGciOiJSUzI1NiIs...",
"X-User-Id": "user_abc123"
}
}
The gateway lets the request through and the backend has to
decide what to do with a missing tenant. The simplest backstop
is a second HTTPRoute match that requires the
header to exist and returns 400 if it does not, so a
misconfigured IdP cannot silently bypass tenant scoping.
response_code_details naming the JWT filter
(jwt_authn_access_denied). The verified-claim
headers should never appear in the upstream cluster's access log
for any rejected request, since the request never reached the
upstream. The exact response strings ("Jwt is missing",
"Audiences in Jwt are not allowed", etc.) are Envoy's
jwt_authn filter
responses and are stable across kgateway versions.
8 · Same pattern on agentgateway
Agentgateway is a Rust data plane built on the ztunnel codebase, not
Envoy. It implements
RFC 7519 (JWT)
and RFC 7517 (JWKS)
with an independent implementation, so the behavioural surface is
broadly comparable to kgateway's Envoy-based jwt_authn:
status codes for the common failure modes (401 on invalid signature,
401 on expired, 403 on audience mismatch) line up. The exact reason
strings are not guaranteed to match. Test your
specific cases against your installed build.
JWT validation EnterpriseAgentgatewayPolicy.spec.traffic.jwtAuthentication
The enterprise policy uses a providers[] array under
traffic.jwtAuthentication with issuer,
audiences and jwks.remote
(jwksPath + backendRef to the JWKS Service).
mode is one of Strict /
Optional / Permissive — note that the
in-code default is Optional (requests without a JWT
pass through), so set Strict explicitly when you want
rejection on missing tokens. Add more entries to
providers[] for multiple issuers.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: jwt-claims, namespace: agentgateway-system }
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: agw-waypoint }
traffic:
jwtAuthentication:
mode: Strict
providers:
- issuer: "https://test-idp.example.com"
audiences: ["api.example.com"]
jwks:
remote:
jwksPath: "/jwks.json"
backendRef:
kind: Service
name: jwks-host
namespace: jwt-test
port: 80
OSS standalone agentgateway accepts the same providers[]
form, and also a flattened single-provider variant with
issuer / audiences / jwks as
top-level keys under jwtAuth. Verify the exact shape
against the CRDs installed in your cluster before copying.
Claims → headers transformation with a CEL expression
Where kgateway has a one-liner claimsToHeaders field,
agentgateway uses traffic.transformation.request with
set / add / remove arrays of
{ name, value }. The value is a CEL
expression; verified JWT claims are exposed as
jwt.<claim> (e.g. jwt.sub,
jwt.tenantId) — not jwt.claims.<claim>.
The remove list strips any inbound copies the client
might have sent, the same defence as
Case 6 self-assertion with
override: true.
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: jwt-claims-to-headers, namespace: agentgateway-system }
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: agw-waypoint }
traffic:
transformation:
request:
remove:
- x-tenant-id # strip any client-supplied copy first
- x-user-id
set:
- name: x-tenant-id
value: "jwt.tenantId"
- name: x-user-id
value: "jwt.sub"
More verbose than claimsToHeaders, more flexible. Any
CEL expression works, so you can concat, default, lowercase or join
claims:
spec:
traffic:
transformation:
request:
set:
- name: x-tenant-id
value: "jwt.tenantId"
- name: x-tenant-tier
value: 'has(jwt.plan) ? jwt.plan : "free"'
- name: x-user-id
value: "jwt.sub.lowerAscii()"
- name: x-org-roles
value: 'jwt.groups.join(",")'
For RBAC keyed off claims (the
agentgw-agentic-mcp Alice/Bob
pattern), use traffic.authorization.policy.matchExpressions
with CEL like jwt.sub == "alice" instead of writing the
claim to a header first.
Negative tests Codes line up, reason strings won't
The seven cases in
section 7 port across in shape —
same tokens, same expected status codes. Case 1 (missing) returns
401, Case 3 (expired) returns 401, Case 4 (wrong audience) returns
403. The body text and the reason string will differ from kgateway
because agentgateway's errors come from its own Rust JWT validator,
not Envoy's jwt_authn filter. For example, a missing token
surfaces as authentication failure: no bearer token
found rather than Envoy's Jwt is missing.
Re-capture the exact strings on your cluster before asserting on
them in CI.
The access log differs accordingly: kgateway records
jwt_authn_access_denied in
response_code_details; agentgateway emits structured
jwt.* fields from its own Rust logger.
claimsToHeaders shortcut and richer
EnterpriseKgatewayTrafficPolicy behaviour for HTTP
workloads. agentgateway is the right home when the same JWT validation
needs to gate MCP tool calls, LLM routes, or sit on an ambient
waypoint with per-user MCP RBAC keyed on jwt.sub — see
agentgw-agentic-mcp · LABs 5–6
for that end-to-end. The same lab adds an
area-by-area
security-audit section mapping the JWT pattern onto the wider
enterprise audit framework.
Where to go next
Once x-tenant-id is a verified header on every
authenticated request, the natural next steps are:
-
Wire a
RateLimitConfigwith atenant-iddescriptor keyed offx-tenant-idfor per-tenant + per-endpoint limits. -
Add
HTTPRoutematches onx-tenant-idfor per-tenant routing, canaries, or feature gating. - If you also need OIDC login at the gateway (not just token validation), or RFC 8693 token exchange for downstream backends, see JWT, OIDC and on-behalf-of.
- For the broader edge-vs-mesh decision, see kgateway vs Istio Ingress Gateway — a decision page.