MastertheMesh
kgateway & agentgateway · Reference
Visual reference

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

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

Route, rate-limit and authorise multi-tenant traffic on a plain x-tenant-id header instead of re-parsing a JWT in every service. The gateway verifies the token signature against the IdP's JWKS once, then lifts named claims out of the verified payload into request headers. On kgateway that is the claimsToHeaders field under entJWT; on agentgateway it is a transformations block with a CEL expression reading jwt.<claim>. From that point HTTPRoute matches, rate-limit descriptors and your app code all key off the header, and the backend never has to see the token.

claimsToHeaders transformations · CEL JWKS EnterpriseKgatewayTrafficPolicy EnterpriseAgentgatewayPolicy x-tenant-id

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:

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.

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.

Shell var Used in What it tests
$VALID_JWTCases 6, 7Happy-path control — has tenantId=tnt_acme.
$TAMPERED_JWTCase 2Signature segment flipped after signing.
$EXPIRED_JWTCase 3exp is one hour in the past.
$WRONG_AUD_JWTCase 4aud=api.other.com.
$WRONG_ISS_JWTCase 5iss not configured on the AuthConfig.
$NO_TENANT_JWTCase 7tenantId claim missing, signature still valid.

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.

What you should see in the logs. For every rejected case above, the access log should show a 4xx with 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.

When to pick which. kgateway at the cluster edge gives you the 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: