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:
{
"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:
- Read the JWT header. Every signed JWT has a header with
algandkid. Thekidtells you which key in the set signed this token. - Resolve the JWKS from the issuer — from cache, from disk, or fetched from the endpoint.
- Match the
kidfrom the header against thekidof a key in the set. - Reconstruct the public key from that JWK and verify the signature over
header.payload. - Validate the claims —
iss,aud,exp,nbf. A valid signature only proves the IdP signed this token — not that it was meant for you. Signature good butaudwrong? 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: 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.
jwt.email.endsWith("@example.com") and the like.
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:
alg: none— an unsigned token accepted as valid.- RS256 → HS256 confusion — the attacker flips the header to a symmetric algorithm and signs with the public key as the HMAC secret. If your verifier trusts the header's
alg, it happily checks an HMAC it can compute itself.
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.
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.
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.