MastertheMesh
Solo · agentgateway · JWT · backend auth · per-team key · kind
Live · Runs on kind

Per-team static key injection: swap a user's JWT for a team API key

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

Every team reaches the LLM carrying its own upstream API key, and no user ever holds a credential (agentgateway validates the user's JWT, routes on the verified team claim, and injects that team's static key via policies.auth.secretRef). Tom in sales lands on the sales key, Ram in engineering on the engineering key, and because the selection comes from a signed claim a client can't reach another team's key by sending a header.

Solo Enterprise AGW jwtAuthentication claim → header policies.auth.secretRef kind

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:

RequestHTTPUpstream received
Tom's JWT, team=sales200Bearer SALES-STATIC-KEY-…, x-team=sales
Ram's JWT, team=engineering200Bearer ENG-STATIC-KEY-…, x-team=engineering
Sales JWT + spoofed x-team: engineering200Bearer SALES-STATIC-KEY-…set overwrote the spoof
No JWT401rejected at the gateway
The order matters more than anything. Without 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

See also

Versions

Built and verified on:

Enterprise
Solo Enterprise for agentgatewayv2.3.4
Gateway APIv1.5.1