What JWKS actually is

JWKS — JSON Web Key Set — is the public-key directory an identity provider publishes so anyone can verify the JWTs it signs, without ever sharing the private signing key. That's the whole point: asymmetric crypto means the IdP keeps the private key locked away and hands out the public half to the world, so your gateway can validate a token's signature locally — without phoning home on every request.

A JWKS is just a JSON document at a well-known URL — conventionally https://{issuer}/.well-known/jwks.json. It contains a keys array, where each entry is a JWK (JSON Web Key) describing one public key:

jwks.json
{
  "keys": [
    {
      "kty": "RSA",        // key type — RSA or EC
      "use": "sig",        // signing, vs "enc"
      "kid": "abc123…",     // the lookup handle
      "alg": "RS256",      // signing algorithm
      "n": "0vx7agoebGcQ…", // RSA modulus
      "e": "AQAB"          // RSA exponent
    }
  ]
}

The fields that matter: kid (key ID — the lookup handle), kty (key type, usually RSA or EC), use: "sig" (signing, vs enc for encryption), alg (the algorithm, typically RS256 or ES256), and the actual key material — n/e for an RSA modulus and exponent, or x/y/crv for an elliptic curve.

The verification flow

Here's how it ties together when a token lands at your gateway:

  1. Read the JWT header. Every signed JWT has a header with alg and kid. The kid tells you which key in the set signed this token.
  2. Resolve the JWKS from the issuer — from cache, from disk, or fetched from the endpoint.
  3. Match the kid from the header against the kid of a key in the set.
  4. Reconstruct the public key from that JWK and verify the signature over header.payload.
  5. Validate the claimsiss, aud, exp, nbf. A valid signature only proves the IdP signed this token — not that it was meant for you. Signature good but aud wrong? Reject it.

Step 5 is non-negotiable, and it's where weak implementations leak.

How agentgateway uses it

Solo's agentgateway is a Rust dataplane, and it implements exactly the validate-locally model above. It's worth seeing how the pieces map onto its JWT auth policy, because a couple of the choices are sharper than the generic story.

You configure a JWT auth policy with an issuer, optional audiences, and a JWKS source. The source can be inline JSON, a file, or a remote URL:

jwtAuth policy
jwtAuth:
  issuer: https://login.example.com/
  audiences: [api.example.com]
  jwks:
    url: https://login.example.com/.well-known/jwks.json
  # jwks can also be a { file: … } path, or an inline JSON string

When the policy is built, agentgateway loads that JWK Set once and pre-computes a lookup table keyed by kid. For every JWK it rebuilds the public key from the raw number fields in the JSON: for an RSA key, the modulus and exponent (the n and e fields you saw above); for an elliptic-curve key, the two coordinates of the curve point (the x and y fields). The enterprise build adds Ed25519 keys on top. Each entry carries its own validation rules: the pinned issuer, the audience set, the expiry check, the allowed algorithms. Any key whose type it can't turn into a verifier is rejected at load time, not at request time.

So by the time a request arrives, the heavy lifting is already done. There's no key parsing and no network hop — just a fast, in-memory check: read the token's kid, find the matching key, verify the signature, and validate the claims. A token with no kid, or one that doesn't match a key in the set, is rejected. Once a token passes, the verified claims are handed off to the rest of the policy chain, so authorization, logging, and rate-limit rules can act on the caller's identity without re-checking the token.

Pinning the algorithm to the key, not the token

This is the part I'd point any security reviewer at. The classic JWT attacks all come from letting the token dictate how it gets verified:

agentgateway closes both structurally. The allowed-algorithm list for a key is derived from the key, never from the incoming token's header. If the JWK declares an alg, that exact algorithm is the only one accepted. If it doesn't, agentgateway infers the family from the key type — an RSA key permits RS256/RS384/RS512, an EC key permits ES256/ES384 — and nothing else. A token arriving with alg: HS256 never matches, because the gateway only ever built RSA and EC verifiers; an HMAC key is never constructed in the first place. And alg: none is rejected for the same reason.

The principle in one line: the attacker proposes an algorithm in the token header; you decide what's acceptable from the key material you already trust. agentgateway never lets the token's header widen that set.

Key rotation and the kid indirection

The whole reason kid exists is rotation. IdPs roll their signing keys periodically, and during the overlap window the JWKS publishes both the old and the new public key, so tokens signed under either remain verifiable. The kid in each token's header is what lets the verifier pick the right one. Because agentgateway loads the entire set into its kid map, an overlap window with two keys is just two entries in the map — tokens from before and after the roll both validate, with no special handling.

The failure mode to understand is what happens at the edge of that window. agentgateway builds its key table when the policy is configured — it does not poll the JWKS endpoint on a timer inside the hot path, and it does not silently re-fetch when an unknown kid shows up. A token whose kid isn't in the table is rejected as an unknown key. That keeps the request path fast and free of a per-request dependency on the IdP — but it means the refresh has to come from the control plane reloading the policy (which re-reads a file or re-fetches the remote URL), not from the dataplane.

Operational consequence: point the gateway at a JWKS source that always serves the full overlap set during a roll — which every real OIDC provider does — and make sure your config pipeline re-fetches it on a cadence comfortably shorter than the IdP's rotation period. The anti-pattern to avoid on any gateway is the opposite extreme: fetching JWKS lazily on the first request after a TTL expiry, which turns every rotation into a latency spike and a hard dependency on IdP availability at the worst possible moment.

Two sharp edges to know

The mode default allows tokenless requests. The policy has three modes. Strict requires a valid token or the request is rejected. Permissive never rejects — it validates if a token is present and otherwise passes the request through for claims to be used downstream. The default is Optional: validate a token if one is present, but allow requests with no token at all. That's a reasonable default for layering auth, but if your intent is “no token, no entry,” you must set Strict explicitly — the policy itself calls this out as a warning.

Required-claim enforcement is narrower than it looks. You can insist that specific claims be present before a token is accepted, but only the standard registered claims — exp, nbf, aud, iss, and sub — are actually enforced. Ask for something like iat or jti and it's quietly ignored. The default is to require exp, so expiry is always checked when present — but don't assume an arbitrary custom claim is being gated just because you listed it.