The pattern: static service-credential injection, with the credential chosen per
team from a verified JWT claim. It builds on the
JWT claims to headers reference — validate the token, lift a
claim into a header, and route on it — and adds the key-injection half: each per-team backend carries
its own policies.auth.secretRef, and the inbound JWT is stripped on the way upstream.
Everything runs in one kind cluster with a mock IdP (mints the per-team JWTs) and an echo upstream
that reports which key it received, so the credential swap is visible without a real per-team provider
account. Bring it up with ./scripts/quick.sh up; source at
github.com/tjorourke/solo-labs/tree/main/agentgateway-team-key-injection-kind.
What you'll build
client ── POST /v1/chat/completions ──▶ agentgateway (Gateway teamkey-gateway)
Authorization: Bearer <user JWT> │
│ EnterpriseAgentgatewayPolicy (phase: PreRouting)
│ jwtAuthentication: verify against the IdP JWKS (Strict)
│ transformation: set x-team = jwt.team (overwrites any client copy)
▼
x-team: sales ───────▶ AgentgatewayBackend team-sales ─┐ policies.auth.secretRef
x-team: engineering ─▶ AgentgatewayBackend team-engineering ─┘ injects the team's static key
│ (the user's JWT is validated, then stripped)
▼
echo upstream (LLM stand-in) — reports the key it received
The order is the load-bearing part: phase: PreRouting runs JWT validation and the
claim→header step before route matching, so the route sees the gateway-derived
x-team (not a client-supplied one), and a request with no JWT is rejected before it can
match anything.
The JWT policy
YAMLyaml/agentgateway/jwt-policy.yaml
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata: { name: team-jwt, namespace: agentgateway-system }
spec:
targetRefs:
- { group: gateway.networking.k8s.io, kind: Gateway, name: teamkey-gateway }
traffic:
phase: PreRouting # run JWT + transform BEFORE route matching
jwtAuthentication:
mode: Strict # default is Optional — missing tokens would pass
providers:
- issuer: "https://mock-idp.teamkey.demo"
audiences: ["agentgateway"]
jwks:
inline: '{ "keys": [ ... ] }' # rendered from the IdP's JWKS at deploy
transformation:
request:
set: # `set` overwrites any client x-team — the anti-spoof
- { name: x-team, value: "jwt.team" } # CEL: jwt.<claim>, not jwt.claims.<claim>
- { name: x-user-id, value: "jwt.sub" }
One backend per team, each with its own key
You can't put two keys on one backend — selection happens by routing to a per-team backend, and each
injects its own static credential via policies.auth.secretRef. With an inbound auth policy
in play, agentgateway strips the client's credential and injects the backend's, so the user never
carries the upstream key.
YAMLyaml/backends/team-sales.yaml (engineering is identical bar the Secret)
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata: { name: team-sales, namespace: agentgateway-system }
spec:
ai:
provider:
openai: { model: echo }
host: echo-upstream.teamkey-demo.svc.cluster.local # spec.ai.provider.host/port
port: 8080
policies:
auth:
secretRef: { name: sales-secret } # the SALES static key (Secret's Authorization)
The HTTPRoute header-matches x-team to the right backend:
YAMLyaml/agentgateway/httproute.yaml (one rule per team)
rules:
- matches: [{ path: { type: PathPrefix, value: /v1/chat/completions },
headers: [{ type: Exact, name: x-team, value: sales }] }]
backendRefs: [{ group: agentgateway.dev, kind: AgentgatewayBackend, name: team-sales }]
- matches: [{ path: { type: PathPrefix, value: /v1/chat/completions },
headers: [{ type: Exact, name: x-team, value: engineering }] }]
backendRefs: [{ group: agentgateway.dev, kind: AgentgatewayBackend, name: team-engineering }]
Run it
Bashbring up + prove the per-team key swap
export AGENTGATEWAY_LICENSE_KEY=...
./scripts/quick.sh up
./scripts/capture-keys.sh
What it does, end to end
Verified live on kind:
| Request | HTTP | Upstream received |
|---|---|---|
Tom's JWT, team=sales | 200 | Bearer SALES-STATIC-KEY-…, x-team=sales |
Ram's JWT, team=engineering | 200 | Bearer ENG-STATIC-KEY-…, x-team=engineering |
Sales JWT + spoofed x-team: engineering | 200 | Bearer SALES-STATIC-KEY-… — set overwrote the spoof |
| No JWT | 401 | rejected at the gateway |
phase: PreRouting, route
matching runs on the inbound headers before the transformation sets x-team — so the
JWT-derived value never reaches the matcher, and a client-supplied x-team would. With
PreRouting, the verified claim is set first and set overwrites any client copy, so the
team a request lands on is exactly the one its signed token says.
Two flavours, and the token-exchange alternative
- One key for everyone: just
AgentgatewayBackend.policies.auth.secretRefon a single backend — strips the user token, injects a shared static key. OSS-capable. - Per-team key (this lab): JWT auth + claim→header + per-team backends. The
jwtAuthenticationandtransformationare Enterprise AGW fields. - Need a user-scoped downstream token rather than a shared team key? That's RFC 8693 token exchange — see token exchange across identity providers.
See also
- JWT claims to headers — verified-claim routing on kgateway and agentgateway (the reference this builds on)
- RFC 8693 token exchange across identity providers
- Solo — agentgateway LLM providers & backend auth
Versions
Built and verified on:
v2.3.4v1.5.1