Skip to content

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.

A rotated key reaches RPs the first time they miss
A relying party reads jwks_uri out of the discovery document, fetches the key set once, and caches it. When a token arrives carrying a kid the cache does not know, it refetches once and then verifies, which is how a rotated key reaches every verifier without a coordinated deploy.RPverifies the tokensOPgo-oidc-providerfirst useGET /.well-known/…read jwks_uriGET /jwksno cached copy yet200 · JWK setwith an ETagcache the set by kidlater lookups skip the networka kid the cache has never seenkid not in the cacherefetch once, don't rejectGET /jwksIf-None-Match: ETag200 · JWK setor 304 if unchangedverify the tokenwith the key just fetched
The refetch on an unknown kid is what makes rotation safe without coordination. An RP that has never heard of the new key finds out the first time it meets one — provided it refetches on a miss rather than rejecting outright.

What's served ​

http
GET /jwks
Accept: application/jwk-set+json

Response body shape:

json
{
  "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 ​

HeaderValueNotes
Content-Typeapplication/jwk-set+jsonper RFC 7517
Cache-Controlpublic, max-age=3600, stale-while-revalidate=36001 h cache, 1 h SWR
ETag"<sha256-hex>"strong validator over the marshalled body
AllowGET, HEADother 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 Modified with no body. The 304 still carries ETag and Cache-Control.
  • * wildcard → 304 Not Modified.
  • Weak validator (W/"...") with the same opaque tag → also matches (the If-None-Match comparison 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-revalidate

Wire 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:

go
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:

  1. Fetches /jwks once at startup (or first verification).
  2. Caches the response per Cache-Control; revalidates with If-None-Match after max-age expires.
  3. On a JWS verification with an unknown kid, refetches /jwks bypassing cache. This is the expected rotation path: the new kid arrives, the verification retries, and succeeds.
  4. Does NOT fall back to a cached key on kid mismatch, 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:

go
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 thin crypto.Signer adapter that calls Sign against 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.Context returns a crypto.Signer directly.

The OP only requires:

  • The active signer's public key matches the signing algorithm: *ecdsa.PublicKey on elliptic.P256() for ES256 (the default), or an RSA key of 2048 bits or more for RS256. A retiring entry may be of the other type if it sets NotAfter; any other shape makes op.New fail.
  • Sign is 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 ​

sh
# 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 Modified

A 304 is the normal hot-path response for an RP with a warm cache.