JWKS endpoint
The /jwks endpoint publishes the public half of the OP's signing keys as a JWKS (JSON Web Key Set, RFC 7517), a JSON document listing public keys by kid. RPs fetch it to verify ID Tokens, JWT access tokens, JARM responses, and any userinfo JWTs. This page is the operational contract between the OP and those RP caches.
What's served
GET /jwks
Accept: application/jwk-set+jsonResponse body shape:
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"kid": "sig-2026-05",
"alg": "ES256",
"use": "sig"
}
]
}Each entry in the signing keyset carries use: "sig" and the algorithm of its key: alg: "ES256" with kty: "EC" and crv: "P-256" by default, or alg: "RS256" with kty: "RSA" when the OP signs with RS256. While the OP switches between the two, both key types are listed. If you configure WithEncryptionKeyset, the OP additionally appends the encryption keyset's public halves (use: "enc") after the signing keys; see Key rotation for how the two keysets rotate independently.
Why the keyset holds one signing algorithm
The JOSE allow-list (RS256, PS256, ES256, EdDSA) is the set the OP verifies — relevant when an RP signs private_key_jwt client assertions or JAR request objects with one of those algs. The keys the OP signs with are of one type, ES256 or RS256; a key of the other type is listed only as a verification-only entry during a switch. See Required options § WithKeyset.
HTTP headers
| Header | Value | Notes |
|---|---|---|
Content-Type | application/jwk-set+json | per RFC 7517 |
Cache-Control | public, max-age=3600, stale-while-revalidate=3600 | 1 h cache, 1 h SWR |
ETag | "<sha256-hex>" | strong validator over the marshalled body |
Allow | GET, HEAD | other methods get 405 |
HEAD returns the same headers (including ETag and Cache-Control) with no body. RP caches normally revalidate with a conditional GET instead, as below.
If-None-Match
The handler honours If-None-Match:
- Exact ETag match →
304 Not Modifiedwith no body. The 304 still carriesETagandCache-Control. *wildcard →304 Not Modified.- Weak validator (
W/"...") with the same opaque tag → also matches (theIf-None-Matchcomparison is weak). - A comma-separated list matches when any member does.
The ETag covers the full marshalled JWKS, so any kid change or key set membership change rolls the value automatically. There is no manual cache-bust step.
Rotation Cache-Control
During a rotation overlap window the handler can advertise a shortened cache:
Cache-Control: public, max-age=300, must-revalidateWire the predicate through op.WithJWKSRotationActive(...) at provider construction. The predicate runs on the request hot path, so it must be cheap and concurrency-safe. Omit the option (or pass nil) to leave the handler in long-cache mode for every response.
A typical pattern:
var rotationUntil atomic.Value // time.Time
rotationUntil.Store(time.Time{})
// On rotation start:
rotationUntil.Store(time.Now().Add(24 * time.Hour))
isRotating := func() bool {
until, _ := rotationUntil.Load().(time.Time)
return time.Now().Before(until)
}
provider, _ := op.New(
/* required options */
op.WithJWKSRotationActive(isRotating),
)After the window passes, the predicate returns false and the long cache resumes. Repeated calls to op.WithJWKSRotationActive are last-wins, so a supervisor can swap predicates without rebuilding earlier option lists.
RP cache behaviour
A well-behaved RP:
- Fetches
/jwksonce at startup (or first verification). - Caches the response per
Cache-Control; revalidates withIf-None-Matchaftermax-ageexpires. - On a JWS verification with an unknown
kid, refetches/jwksbypassing cache. This is the expected rotation path: the newkidarrives, the verification retries, and succeeds. - Does NOT fall back to a cached key on
kidmismatch, because that would defeat rotation auditing.
The OP applies the same rule when it verifies its own JWT access tokens at /userinfo and /introspect: it looks the kid up in the configured keyset, and a kid that is unknown or past its NotAfter is refused. There is no "trust the active key for an unknown kid" branch.
Multiple keys in JWKS
The keyset advertises every entry in the configured op.Keyset:
op.WithKeyset(op.Keyset{
{KeyID: "sig-2026-05", Signer: newPriv}, // active — first signs
{KeyID: "sig-2026-02", Signer: prevPriv}, // retiring — verify only
})Both kid values appear in /jwks. The OP signs new tokens with the first entry; the second is kept in JWKS so RPs can still verify tokens issued before the rotation. See Key rotation for cadence.
An entry stays in /jwks until you rebuild the Keyset without it. Setting SigningKey.NotAfter on a retiring entry makes the OP itself stop trusting that kid at the deadline (it also emits key.retired_kid_presented when one shows up), but the public key stays published so RP caches can drain. Encryption keys behave differently: an EncryptionKey past its NotAfter is removed from /jwks as well.
HSM / KMS integration
op.SigningKey.Signer is crypto.Signer — anything implementing Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) fits. Common shapes:
- AWS KMS:
kms.NewFromConfig(cfg)+ a thincrypto.Signeradapter that callsSignagainst the KMS key. - Azure Key Vault:
azkeys.Client+ adapter. - HashiCorp Vault Transit: HTTP signer adapter.
- Google Cloud KMS:
kms.NewKeyManagementClient+ adapter. - PKCS#11 / hardware HSM:
crypto11.Contextreturns acrypto.Signerdirectly.
The OP only requires:
- The active signer's public key matches the signing algorithm:
*ecdsa.PublicKeyonelliptic.P256()forES256(the default), or an RSA key of 2048 bits or more forRS256. A retiring entry may be of the other type if it setsNotAfter; any other shape makesop.Newfail. Signis safe for concurrent use. A thin adapter must guarantee this itself if its client is not.
A KMS-backed signer adds latency to token issuance (typically 10 – 50 ms per signature). Plan capacity accordingly.
Discovery jwks_uri
The discovery document advertises jwks_uri as the absolute URL of the configured JWKS path under your issuer and mount prefix (/oidc by default). RPs fetch discovery once and follow the jwks_uri from there — there is no hard-coded path the OP relies on.
You can override the path with WithEndpoints(op.Endpoints{JWKS: "/keys"}) if you have a router conflict; the discovery document follows.
Verifying the contract
# Headers — confirm Cache-Control and ETag.
curl -I https://op.example.com/jwks
# Body shape — confirm one entry per active+retiring kid.
curl -s https://op.example.com/jwks | jq '.keys[] | {kid, alg, use}'
# Revalidation — confirm 304 on matching ETag.
ETAG=$(curl -sI https://op.example.com/jwks | awk '/[Ee][Tt]ag/ {print $2}' | tr -d '\r')
curl -I -H "If-None-Match: $ETAG" https://op.example.com/jwks
# → HTTP/1.1 304 Not ModifiedA 304 is the normal hot-path response for an RP with a warm cache.