Key rotation
Two distinct keys rotate on different cadences:
- Signing keys (
op.Keyset) — the ECDSA P-256 private keys used to sign ID tokens, JWT access tokens, JARM, and userinfo JWTs. Public half lives in/jwks. - Cookie keys (
op.WithCookieKey/WithCookieKeys) — the 32-byte AES-256-GCM keys used to seal session and CSRF cookies.
Both are rotated by constructing a new *Provider and atomically swapping the handler. The library never mutates the slice in place.
Signing key rotation
Cadence
- Routine: every 60 – 90 days.
- Compromised: immediately, with the retired key dropped from JWKS after every outstanding token has expired (your access-token TTL bound).
Procedure
// Step 1. Generate the new key.
newPriv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// Step 2. Build a Keyset where the NEW key is first (active signer)
// and the previous key is retained for verification.
ks := op.Keyset{
{KeyID: "sig-2026-05", Signer: newPriv}, // active
{KeyID: "sig-2026-02", Signer: prevPriv}, // retiring; verifies recent tokens
}
// Step 3. Construct a new Provider and atomically swap.
newProv, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(myStore),
op.WithKeyset(ks),
op.WithCookieKey(cookieKey),
/* ... rest of options ... */
)
if err != nil { /* retain old provider, alert */ }
// Step 4. Atomically replace the live handler.
liveHandler.Store(newProv) // typically an *atomic.Value or sync.MapThe first entry in the Keyset is always the active signer; subsequent entries are advertised in JWKS for verification only. RPs that have a JWKS already cached pick up the new kid on their next refresh — see JWKS endpoint for the cache headers.
When to drop the retiring key
Wait at least:
- The longest issued token TTL (default
WithAccessTokenTTL= 5 min). - Plus the longest RP-side JWKS cache window (
max-age=86400by default — 24 h).
So the safe minimum is 24 hours after rotation before dropping the old kid. Operators with shorter access-token TTL still want to wait the JWKS cache window so RPs that mid-rotation cached the old set don't reject a token they could have validated.
Rotation overlap signal
Pass op.WithJWKSRotationActive(predicate) at provider construction — when the predicate returns true, the JWKS response advertises a 5-minute Cache-Control: public, max-age=300, must-revalidate instead of the long-cache default (max-age=86400, stale-while-revalidate=3600). Hold the predicate true during the overlap window so RP caches refresh promptly. See JWKS endpoint.
Cookie key rotation
Cadence
- Routine: quarterly (low risk; the cookie scope is
__Host-and the lifetime is bounded by the session TTL). - Compromised: immediately.
Procedure
// Generate a fresh 32-byte key.
newKey := make([]byte, 32)
if _, err := rand.Read(newKey); err != nil { /* abort */ }
// First key encrypts; subsequent keys are tried for decryption.
op.WithCookieKeys(newKey, oldKey)WithCookieKeys retains the old key in the decrypt slot so live sessions stay valid until the user re-authenticates or the session TTL expires. After every outstanding session has expired (or after you force a logout fan-out), drop oldKey from the slice.
Order matters
The first key in WithCookieKeys is the active encryption key. Putting the old key first silently downgrades new sessions to the compromised key. The order is enforced by convention, not type — so review carefully on every rotation.
MFA encryption key rotation
WithMFAEncryptionKey / WithMFAEncryptionKeys follow the same "current first, previous in tail" shape as cookie keys. The 32-byte key seals TOTP secrets at rest (AES-256-GCM, with the subject identifier as additional authenticated data).
op.WithMFAEncryptionKeys(currentKey, previousKey)Retire previousKey only after every persisted TOTP record has been re-sealed under currentKey. This is your store's migration path — the OP does not re-seal automatically.
What you do not rotate
- Issuer. Changing
WithIssuerinvalidates every issued token (issis part of the JWT) and every RP's discovery cache. Treat as a deployment, not a rotation. KeyIDs in yourKeyset. Once advertised in JWKS, akidis a public identifier RPs cache. Reusing akidafter dropping it causes a verification gap if any RP cached the old public key. Generate freshkidvalues per rotation.
Verification checklist
After every rotation:
curl https://op.example.com/jwks— confirm the newkidis present and the retiredkidis still listed (unless you've completed the drop step).- New token
kidheader points at the new key:echo "<jwt>" | cut -d. -f1 | base64 -d | jq .kid. token.issuedaudit events show the newkidin extras.- Browser sessions established before rotation still resolve at the
/userinfoendpoint (cookie key rotation didn't break decrypt).
Why a new Provider instead of a mutate-in-place API
Rotation changes a fundamental security property of the OP. Forcing a fresh op.New(...) call:
- Re-runs every option-conflict check (so a typo can't survive).
- Atomically replaces the verification path (no half-rotated state).
- Surfaces the change as a fresh log line / audit event from the supervisor, not a silent in-process mutation.
The supervisor pattern is small enough that the safety win is worth the verbosity.