The pattern shows up in most multi-tenant kgateway setups that
use JWT/OIDC: the IdP issues a JWT with a tenantId
claim, kgateway verifies the signature, and downstream config
keys off the tenant as a plain header. This page walks through the JWT envelope, the
four-step request flow, and why getting the verified claim into a
header up front matters for the rate-limit and routing layers that
sit behind it.
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.
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.