Customer question. "We run a public API, one deployment per region. Some of our customers are pinned to an older version of the application, and those versions run on their own separate clusters. We want a gateway in front of all of them that sends each request to the right versioned cluster, and the gateway has to live outside those clusters so the newest one does not have to know the older ones exist. How can we route a request to the correct version, whether that version comes from a header the client sends or from something in the caller's identity, and fall back to the latest version when there is nothing to go on?"
Today that routing tends to be done by reverse-proxying from the latest
cluster, which is fragile: the newest deployment ends up owning everyone
else's routing. This lab moves the routing table out of the application
clusters and into a dedicated kgateway in front of them. Each versioned
cluster becomes a plain destination, reached as an out-of-cluster
Backend. The gateway decides which one a request goes to, from
an explicit version header or by reading a version claim out of the caller's
JWT, and defaults to latest when neither is present.
What you'll build
edge · kgw-edge
The gateway
Solo Enterprise for kgateway 2.2.0 and nothing else. Holds the
Gateway, the HTTPRoute, the two
Backends and the JWT policy.
app · app-latest
latest
An echo app that reports servedBy: latest and shows the
headers it received. Exposed on a NodePort.
app · app-v2
v2
A pinned older version. Same echo app, reporting
servedBy: v2. Exposed on a NodePort.
All three are kind clusters on the shared docker network. The edge gateway
reaches each app cluster as a static Backend whose host is the
app cluster's node IP and whose port is the NodePort. Nothing routes by pod
IP across clusters, so the app clusters stay completely independent of one
another. The gateway is the only thing that knows the full set of versions.
Prerequisites
kind,kubectl,helm,docker,opensslandjqon your path.gcloud, authenticated. The enterprise chart lives on a public Google Artifact Registry, but a helm OCI pull still needs a registry login with a gcloud access token. The script handles the login for you.- A Solo Enterprise for kgateway license key. Export
KGATEWAY_LICENSE_KEY, or pointSECRETS_FILEat a sourceable file (the script also acceptsGLOO_GATEWAY_LICENSE_KEYorSOLO_LICENSE_KEY).
Steps
Step 1 — Bring the whole thing up
One command creates the three clusters, installs kgateway, deploys the apps and wires the routing. It is idempotent, so a re-run just reconciles. The rest of the steps explain what each phase does.
bash quick.sh up
# This lab needs ONE secret: a Solo Enterprise for kgateway license key.
# It is read from the first of these that is set:
# KGATEWAY_LICENSE_KEY (preferred)
# GLOO_GATEWAY_LICENSE_KEY
# SOLO_LICENSE_KEY
# Option A — export it in your shell:
export KGATEWAY_LICENSE_KEY="your-license-key"
./scripts/quick.sh up
# Option B — keep it in a sourceable file and point SECRETS_FILE at it.
# secrets.sh just needs the one line:
# export KGATEWAY_LICENSE_KEY="your-license-key"
SECRETS_FILE=/path/to/secrets.sh ./scripts/quick.sh up
# individual phases, if you'd rather step through:
./scripts/01-clusters.sh # 3 kind clusters + Gateway API CRDs on the edge
./scripts/02-kgateway.sh # install enterprise-kgateway + the Gateway
./scripts/03-apps.sh # deploy the version-stamped echo apps
./scripts/04-routing.sh # Backends + JWKS + JWT policy + HTTPRoute
Step 2 — The three clusters
Three single-node kind clusters with distinct pod and service CIDRs so nothing collides on the shared docker network. The Gateway API CRDs (v1.5.1, the version kgateway 2.2 wants) go on the edge cluster only. The app clusters need nothing but a place to run the echo app.
yaml kind/edge.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: kgw-edge
networking:
podSubnet: "10.30.0.0/16"
serviceSubnet: "10.98.0.0/16"
nodes:
- role: control-plane
Step 3 — Install Solo Enterprise for kgateway on the edge
Two charts from the public OCI registry: the CRDs chart, then the control
plane. The license goes in as licensing.licenseKey. Creating the
Gateway with gatewayClassName: enterprise-kgateway
auto-provisions the Envoy proxy and the enterprise sidecars (ext-auth,
ext-cache, rate-limiter, waf).
bash 02-kgateway.sh (the helm calls)
REG=oci://us-docker.pkg.dev/solo-public/enterprise-kgateway/charts
helm upgrade --install enterprise-kgateway-crds \
$REG/enterprise-kgateway-crds --version 2.2.0 \
--namespace kgateway-system --create-namespace --wait
helm upgrade --install enterprise-kgateway \
$REG/enterprise-kgateway --version 2.2.0 \
--namespace kgateway-system \
--set licensing.licenseKey="$KGATEWAY_LICENSE_KEY" --wait
yaml yaml/edge/gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: http
namespace: kgateway-system
spec:
gatewayClassName: enterprise-kgateway
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
Step 4 — Deploy the versioned echo apps
The same echo app goes into both app clusters with a different
CLUSTER_VERSION baked in. It is a stdlib-only Python server run
straight off the stock python:3.12-alpine image with the script
mounted from a ConfigMap, so there is no image to build. It answers any path
with JSON that makes the routing outcome obvious: which cluster served the
request, and the request headers the gateway forwarded. It is exposed on
NodePort 30080.
Step 5 — Wire the Backends, the JWT policy and the route
Two static Backends, one per versioned cluster. The host and port
are the app cluster's node IP and NodePort, discovered and filled in at setup
time.
yaml yaml/edge/backends.yaml
apiVersion: gateway.kgateway.dev/v1alpha1
kind: Backend
metadata:
name: app-latest
namespace: kgateway-system
spec:
type: Static
static:
hosts:
- host: 192.168.97.15 # app-latest node IP
port: 30080 # NodePort
---
apiVersion: gateway.kgateway.dev/v1alpha1
kind: Backend
metadata:
name: app-v2
namespace: kgateway-system
spec:
type: Static
static:
hosts:
- host: 192.168.97.16 # app-v2 node IP
port: 30080
The JWT policy validates the token against an inline JWKS and maps the
version claim into x-target-version.
validationPolicy: AllowMissing lets tokenless requests through,
so the override and default paths keep working, while a present but invalid
token is still rejected. The JWKS is inline (jwks.local.key), so
the whole lab runs offline with no JWKS server, and
mint-token.sh signs matching RS256 tokens.
yaml yaml/edge/jwt-policy.yaml
apiVersion: enterprisekgateway.solo.io/v1alpha1
kind: EnterpriseKgatewayTrafficPolicy
metadata:
name: jwt-version
namespace: kgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: http
entJWT:
beforeExtAuth:
validationPolicy: AllowMissing
providers:
tenant-idp:
issuer: versioned-routing-lab
audiences:
- public-api
tokenSource:
headers:
- header: authorization
prefix: "Bearer "
jwks:
local:
key: |
{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256",
"kid": "versioned-routing-key", "n": "...", "e": "AQAB" } ] }
claimsToHeaders:
- claim: version
header: x-target-version
The route matches an explicit override header first, then the JWT-derived header, then falls through to latest. Gateway API ranks a rule with a header match above the bare path rule, and breaks ties by rule order, so this gives override > claim > default. The two version inputs sit on two different headers on purpose, which the demo explains.
yaml yaml/edge/httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: versioned-routing
namespace: kgateway-system
spec:
parentRefs:
- name: http
namespace: kgateway-system
rules:
# 1. explicit client/ops override (not touched by the JWT filter)
- matches:
- headers:
- name: x-version-override
value: v2
type: Exact
backendRefs:
- group: gateway.kgateway.dev
kind: Backend
name: app-v2
# 2. JWT-claim-derived version
- matches:
- headers:
- name: x-target-version
value: v2
type: Exact
backendRefs:
- group: gateway.kgateway.dev
kind: Backend
name: app-v2
# 3. default to latest
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- group: gateway.kgateway.dev
kind: Backend
name: app-latest
Run the demo
./scripts/quick.sh demo port-forwards the gateway proxy to
localhost:8080 and runs seven requests. The echo app reports
which cluster served each one and which headers it saw, so every row below is
observed against the running gateway, not assumed.
bash mint a token and drive it yourself
kubectl --context kind-kgw-edge -n kgateway-system port-forward svc/http 8080:80 &
TOKEN=$(./scripts/mint-token.sh v2 acme) # version=v2, tenant=acme
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/ | jq .servedBy
# "v2"
curl -s -H "x-version-override: v2" http://localhost:8080/ | jq .servedBy
# "v2"
| Request | Served by | Why |
|---|---|---|
| no header, no token | latest | the default rule |
x-version-override: v2 | v2 | explicit client header, highest precedence |
client sends x-target-version: v2 | latest | the JWT filter strips this header before routing |
JWT version=v2 | v2 | claim projected into the header and re-routed |
JWT version=latest | latest | claim routed |
JWT version=v2 + x-version-override: latest | latest | explicit override beats the claim |
| invalid JWT | 401 | AllowMissing still rejects a bad token |
x-target-version itself, that value is
stripped before routing, even with no token present. The JWT filter owns that
header. A caller cannot fake their version by setting the header the gateway
fills from the claim. Any other client header passes through untouched, which
is exactly why an explicit override needs its own header name
(x-version-override) that the filter does not manage. Precedence
ends up clean: explicit override beats the claim, the claim beats the default.
The JWT-claim path also confirms something worth knowing about kgateway:
claimsToHeaders sets the header early enough that the
HTTPRoute re-evaluates and routes on it, and the same header
reaches the backend. So a version claim is a real routing input, not just
metadata for the upstream.
Keeping the tenant-to-version map current
Keep the map in the IdP as a claim, keyed off the tenant. A version rollover
is then an identity change: update the claim the IdP issues for that tenant,
and the gateway config does not change. There is no separate lookup table to
keep in sync on the gateway, and no header for a tenant to spoof. Clients that
have no version claim, plus ops doing a forced test, use the explicit
x-version-override header instead.
Issuing the version claim from a real IdP — Frontegg
The lab signs its own RS256 tokens against an inline JWKS so it runs offline.
In production the version claim comes from your IdP, keyed off the
user or tenant. Here is how to issue it from Frontegg, a hosted
CIAM provider, so the same claimsToHeaders policy routes on a real
identity. Sample environment host throughout: acme.frontegg.com.
1. Set the per-user version as user metadata. Frontegg carries
arbitrary per-user data in a metadata field and surfaces it in the
access token as a userMetadata claim. Set it from the portal
(Users → the user → Metadata) or with the management API:
bash set Alice's version = v2
# VTOK = a vendor (environment) JWT from POST https://api.frontegg.com/auth/vendor/
curl -s -X PUT "https://api.frontegg.com/identity/resources/users/v1/$ALICE_USER_ID" \
-H "Authorization: Bearer $VTOK" \
-H 'Content-Type: application/json' \
-d '{"metadata":"{\"version\":\"v2\"}"}'
A token minted for that user now carries the value under userMetadata
(this is a real decoded Frontegg token, host redacted to the sample):
json Alice's Frontegg access token (decoded)
{
"sub": "<client-id>",
"name": "Alice",
"email": "alice@acme.io",
"userMetadata": { "version": "v2" },
"tenantId": "acme",
"roles": ["Admin"],
"userId": "<alice-user-id>",
"iss": "https://acme.frontegg.com",
"aud": "<environment-id>"
}
2. Read the nested claim directly. Frontegg nests the value
under userMetadata, and nested claims are supported:
claimsToHeaders maps the claim name straight onto Envoy's
claim_to_headers, which resolves a dot-separated path into a nested
object. So point the claim at userMetadata.version and the gateway
pulls out v2 on its own:
yaml claimsToHeaders · read the nested Frontegg claim
claimsToHeaders:
- claim: userMetadata.version # dot-path into the nested object
header: x-target-version
The dot path resolves to v2, which lands in
x-target-version — the same header the route already matches, so the
routing table doesn't change. (Envoy base64-encodes a claim only when the value
is itself an object; a leaf string like version is copied verbatim.)
3. Point the JWT policy at Frontegg instead of the inline key.
This is the same yaml/edge/jwt-policy.yaml from Step 5, with the
provider swapped to Frontegg: a real issuer, a remote
JWKS instead of the inline key, and the nested claim (confirm issuer and JWKS from
Authentication → SSO → Identity Provider → OpenID Connect Endpoints).
Apply it to the edge cluster in place of the lab's self-signed version:
yaml yaml/edge/jwt-policy.yaml · Frontegg
apiVersion: enterprisekgateway.solo.io/v1alpha1
kind: EnterpriseKgatewayTrafficPolicy
metadata:
name: jwt-version
namespace: kgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: http
entJWT:
beforeExtAuth:
validationPolicy: AllowMissing
providers:
tenant-idp:
issuer: https://acme.frontegg.com
audiences:
- <environment-id> # Frontegg aud = the environment / client id
tokenSource:
headers:
- header: authorization
prefix: "Bearer "
jwks:
remote:
url: https://acme.frontegg.com/.well-known/jwks.json
claimsToHeaders:
- claim: userMetadata.version
header: x-target-version
Teardown
bash quick.sh teardown
./scripts/quick.sh teardown # deletes kgw-edge, app-latest and app-v2
See also
- Part 2 — the same lab on Solo Enterprise for agentgateway
- Solo Enterprise for kgateway docs
- kgateway vs Istio ingress — picking a north-south gateway
- Per-team LLM token budgets at the gateway — JWT claims to headers, the agentgateway sibling
Versions
Built and verified on:
2.2.0v1.5.1