MastertheMesh
kgateway · Reference
Visual reference

JWT claims to HTTP headers — verified-claim routing on kgateway

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

The claimsToHeaders field on a kgateway AuthConfig is the small piece that makes everything downstream simple. After kgateway verifies the JWT signature against the IdP's JWKS, named claims get lifted out of the verified payload into request headers — and from that point on HTTPRoute matches, rate-limit descriptors and your app code key off x-tenant-id without ever re-parsing the token.

claimsToHeaders AuthConfig JWKS EnterpriseKgatewayTrafficPolicy x-tenant-id

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

Client Authorization: Bearer <jwt> kgateway AuthConfig + EnterpriseKgatewayTrafficPolicy 1 · Verify signature remoteJwks.url → JWKS cacheDuration: 300s 2 · Extract claims tenantId → x-tenant-id sub → x-user-id 3 · Forward with verified-claim headers (no token) x-tenant-id: tnt_acme x-user-id: user_abc123 IdP · JWKS endpoint /.well-known/jwks.json public signing keys Backend app · trusts headers never sees the JWT fetch + cache 5 min cyan = kgateway data plane · purple = IdP JWKS fetch · plain arrows = request path

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.

Routing. An 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.
Rate limiting. The rate-limit server keys descriptors on request headers. The "per-tenant + per-endpoint" rate limit pattern is two descriptors — 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.
App code. The backend reads 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: