Key rotation
Three distinct key roles rotate on different cadences:
- Signing keys (
op.Keyset) — the private keys (ECDSA P-256 by default, RSA when the OP signs withRS256) used to sign ID Tokens, JWT access tokens, JARM, and userinfo JWTs. Public half lives in/jwks. - Cookie keys (
op.WithCookieKeys) — the 32-byte AES-256-GCM keys used to seal the session, interaction, and CSRF cookies. - Inbound encryption keys (
op.EncryptionKeyset) — the private keys used to decrypt client-encrypted request objects. Their public halves are published asuse=enckeys in/jwks; they are not outbound encryption keys.
All three are rotated by constructing a new *Provider and atomically swapping it in. op.Reloadable does the swap for you (see Reload without a restart). The library never mutates a key slice in place.
Signing key rotation
Cadence
- Routine: every 60 – 90 days; retain the retiring key through the normal overlap, then remove it after the token-lifetime and RP-JWKS-cache bounds below.
- Compromised: replace it immediately and rebuild the
Keysetwithout the compromised entry so it is removed from JWKS promptly. Do not keep a compromised key for the routine overlap; handle tokens signed with it under your incident-response and revocation procedure.
Procedure
Keep one file per key and let the OP read them. op.LoadKeyset takes the active key's path first and the retiring keys after it, the same order op.WithKeyset uses:
// Step 1. Generate the new key and write it to the key directory,
// for example: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
// The previous active file becomes the retiring one.
// Step 2. Load the new key first and the previous key after it.
ks, err := op.LoadKeyset("/etc/op/keys/signing.pem", "/etc/op/keys/signing-retiring.pem")
if err != nil { /* keep serving the old configuration, alert */ }
// Step 3. Optional: bound how long the OP itself trusts the retiring kid.
// NotAfter is zero when loaded; see "When to drop the retiring key".
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)
// Step 4. Hand the Keyset to a reload (next section).
opts := append(baseOptions, op.WithKeyset(ks))op.LoadSigningKey(path) loads a single key. Both accept a PKCS#8 PEM, a SEC1 or PKCS#1 PEM, or a private JWK (kty EC or RSA), and only ECDSA P-256 or RSA of 2048 bits or more: an encrypted PEM, another curve, a shorter RSA key, or a malformed file is an error that names the path and never carries key material. The kid is the JWK's kid member when it has one, otherwise the RFC 7638 SHA-256 thumbprint of the public key, so every replica that mounts the same file publishes the same kid. LoadKeyset rejects a kid that appears twice, including the same key loaded from two files.
The first entry in the Keyset is always the active signer; the rest are advertised in JWKS for verification only. RPs that have a JWKS already cached pick up the new kid on their next refresh, or immediately when they meet a kid they do not know. See JWKS endpoint for the cache headers.
NotAfter on a retiring entry is a hard stop for the OP's own verification: from that instant the OP rejects a token that names the kid, including its own JWT access tokens at /userinfo and /introspect, and records key.retired_kid_presented. The public key stays in /jwks until you rebuild the Keyset without it. Set retireAfter to the end of the overlap computed below, never to the rotation instant. The active entry must have no NotAfter or one in the future; if it has already reached NotAfter, op.New fails.
When to drop the retiring key
Count from the moment the new provider starts serving, and wait for both of these:
- The longest lifetime of anything the old key signed: access tokens (default
WithAccessTokenTTL= 5 min) and ID tokens. - The RP-side JWKS cache window.
/jwksis served withmax-age=3600, stale-while-revalidate=3600, so a cache can take up to 2 h to turn over.
With the defaults that is about 2 hours and 5 minutes (an ID token that lives longer extends it). An operator with longer-lived tokens adds that lifetime on top. Dropping earlier means a verifier that fetches /jwks afterwards cannot check a token the old key signed.
Rotation overlap signal
Pass op.WithJWKSRotationActive(predicate) at provider construction. While the predicate returns true, /jwks answers with Cache-Control: public, max-age=300, must-revalidate (5 minutes) instead of the long-cache default, so RP caches refresh promptly. Hold it true during the overlap window. With op.Reloadable, the predicate can read a flag or deadline that outlives each reload, so one closure covers the rotation reload and the later one that drops the retiring key. See JWKS endpoint.
Switching the signing algorithm
The OP signs with one algorithm: ES256 by default, RS256 after op.WithSigningAlgorithm(op.AlgRS256). The active key (the first Keyset entry) must be of the matching type, so changing the algorithm means changing the key type, and it follows the same overlap rule as a routine rotation. Tokens issued before the switch keep verifying only while the old-type key stays in the Keyset with a NotAfter.
- Generate an RSA key of 2048 bits or more, for example
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072. - Put it first in the
Keysetand keep the old EC key after it with aNotAfter. An entry whose type differs from the signing algorithm must carry one, orop.Newfails. - Add
op.WithSigningAlgorithm(op.AlgRS256)and reload. - After
NotAfter, reload again with the RSA key alone.
// Steps 1-2. The RSA key is active; the previous EC key verifies only.
ks, err := op.LoadKeyset("/etc/op/keys/signing-rsa.pem", "/etc/op/keys/signing-ec.pem")
if err != nil { /* keep serving the old configuration, alert */ }
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)
// Step 3. Give WithKeyset and WithSigningAlgorithm once each, in one option list.
opts := append(baseOptions, op.WithKeyset(ks), op.WithSigningAlgorithm(op.AlgRS256))
if err := r.Reload(ctx, opts...); err != nil { /* the previous configuration keeps serving */ }
// Step 4, after NotAfter: reload with op.Keyset{rsaEntry} only.Choose NotAfter with the arithmetic in When to drop the retiring key. While both entries are present, /jwks carries both key types, but discovery advertises only the algorithm the OP issues with: id_token_signing_alg_values_supported, userinfo_signing_alg_values_supported, authorization_signing_alg_values_supported and introspection_signing_alg_values_supported read ["RS256"] from the first reload on.
A Reload that leaves out the old active key is refused with op.ErrReloadIncompatible, so the switch cannot skip the overlap. The same steps in reverse move an RS256 OP back to ES256. A signing key and an encryption key must be distinct; op.New refuses a WithEncryptionKeyset entry that reuses the public key of a signing key. RS256 is refused together with profile.FAPI2Baseline, profile.FAPI2MessageSigning and profile.FAPICIBA.
What relying parties see:
- RPs that pin
ES256reject every token the OP issues after the switch until they re-read discovery and acceptRS256. Tell them before the reload. - RPs registered through Dynamic Client Registration with
id_token_signed_response_alg=ES256hold a value that no longer matches what the OP issues. The OP does not issue per client, so the stored value stays as it is, but aPUTof that unchanged metadata to the registration management endpoint is rejected withinvalid_client_metadata. The RP must sendid_token_signed_response_alg=RS256(or omit it, in which case the OP fills inRS256). Registrations made after the switch default toRS256. - Publishing an RSA public key exposes RPs whose JOSE library still allows an RSA public key to be used as an HS256 secret; RPs should pin the expected
algwhen they verify. The OP's own verifiers already require the JWS headeralgto equal the key's algorithm.
To test an RP against an RS256 OP, build the test provider with testkit.WithSigningAlgorithm(op.AlgRS256): it generates an RSA 2048 key and passes op.WithSigningAlgorithm to op.New. Provider.SignedJWT signs a fixture with the active key, RS256 or ES256 to match, and stamps the testkit's kid.
Inbound encryption key rotation
op.EncryptionKeyset is the OP's inbound-only JWE keyset for encrypted request objects at /auth and /par. It never supplies an active key for outbound responses: those JWE responses are encrypted to a use=enc key from the client's JWKS.
encKeys := op.EncryptionKeyset{
{KeyID: "enc-2026-09", PrivateKey: newEncPriv},
{KeyID: "enc-2026-05", PrivateKey: oldEncPriv, NotAfter: oldEncExpiry},
}
newProv, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(myStore),
op.WithKeyset(signingKeys),
op.WithEncryptionKeyset(encKeys),
op.WithCookieKeys(cookieKey),
/* ... rest of options ... */
)Entries are ordered inbound decryption candidates and are published as use=enc. A JWE with kid selects its matching live entry. A kidless JWE is filtered to live keys in the same family as its protected alg; if more than op.MaxKidlessEncryptionTrialKeys (default: 4) remain, decryption is refused rather than truncated. op.New warns when a configured family exceeds that limit, so RPs should include kid.
Publish the new and retiring entries through the RP JWKS cache window and the longest accepted request-object lifetime. NotAfter is a hard cutoff: on or after it, the OP refuses that key and removes its public half from JWKS. Remove the retiring entry after the overlap; outbound response encryption has no OP-side key to rotate.
Cookie key rotation
Cadence
- Routine: quarterly (low risk; the cookies use the
__Host-prefix and their lifetime is bounded by the session lifetime). - 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)Or read the keys from files with op.LoadSymmetricKeys, which returns them in the order given (the first path is the active key):
keys, err := op.LoadSymmetricKeys("/etc/op/keys/cookie.key", "/etc/op/keys/cookie-retiring.key")
if err != nil { /* ... */ }
op.WithCookieKeys(keys...)Each file holds exactly 32 raw bytes, 64 hex characters, or base64 of 32 bytes; surrounding whitespace is ignored. Any other length is rejected, including a 32-character printable-ASCII file such as the output of openssl rand -hex 16, which carries only 128 bits of entropy. Generate keys with openssl rand -hex 32.
WithCookieKeys retains the old key in the decrypt slot so live sessions stay valid. Sessions default to 14 days idle and 30 days absolute, so keep oldKey for up to 30 days; dropping it earlier signs out the users whose cookies were sealed with it. Once every such session has ended, remove oldKey from the slice. With more than one replica, roll the change out in the three steps described in Multi-instance deployment, so that no replica seals a cookie another cannot open.
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
WithMFAEncryptionKeys follows 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). A StepTOTP with its own EncryptionKey uses that key instead.
op.WithMFAEncryptionKeys(currentKey, previousKey)op.LoadSymmetricKeys reads these keys too. 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.
Reload without a restart
op.NewReloadable builds the first *Provider with op.New and returns an http.Handler that serves whichever provider is current. Reload builds a replacement and swaps it in; a build failure leaves the running provider serving. Pass the same function's option list at start-up and on every reload, so a reload re-reads the key files and nothing else drifts:
r, err := op.NewReloadable(buildOptions()...)
if err != nil { log.Fatal(err) }
srv := &http.Server{Addr: ":8080", Handler: r}
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
if err := r.Reload(ctx, buildOptions()...); err != nil {
slog.Error("reload rejected; the previous configuration keeps serving", "error", err)
}
cancel()
}
}()A router that mounts the OP pattern by pattern registers r for each entry of r.Routes(). Call r.Shutdown(ctx) when the process stops.
What a reload keeps and what it asks of you:
Complete option list.
Reloadtakes every option, exactly asop.Newdoes, not a delta.Same store. Sessions, interactions, lockout counters, and every other persisted record survive only because the new provider reads the store the old one wrote. Reconnecting to the same database through a new handle is fine; a different backend drops that state.
Issuer and routes are fixed. A changed issuer or route set is refused with an error that wraps
op.ErrReloadIncompatible(match it witherrors.Is); change those by restarting the process. When the route set is what changed, the replacement had already been built, so newWithStaticClientsseeds are already in the store. Restarting with the same options applies the same seeds.Retiring keys stay listed second. Put the new cookie key or MFA key first and keep the retiring one as a later entry in
op.WithCookieKeysandop.WithMFAEncryptionKeys. Dropping a key the running sessions or enrolled TOTP secrets were sealed with makes them unreadable: every user is logged out and TOTP enrolments must be redone. Keep the retiring signing key in theKeysetuntil the tokens it signed have expired.Key continuity is enforced.
Reloadrefuses, withop.ErrReloadIncompatible, and the running provider keeps serving, when the new options would lose what the running provider handed out:- a
WithCookieKeyslist, or aWithKeyset, that does not contain the running active (first) key. A key with the sameKeyIDbut different key material counts as missing; - an option list that drops any MFA encryption key the running configuration holds, whether set through
WithMFAEncryptionKeysor throughStepTOTP.EncryptionKey/EncryptionKeyPrev. Retire an MFA key by restarting once every TOTP record is re-sealed; - a
WithDPoPNonceSourcethat is not the same instance as the running one.
Rotation therefore takes two reloads: first
[new, old], later[new]. For the cookie key and the signing keyset the second reload is allowed because the new key is by then the running active key.- a
In-memory state. Construct the DPoP nonce source once, outside the option builder, and pass the same instance to every build; a different instance is refused, because nonces the old one issued would stop verifying. A predicate handed to
op.WithJWKSRotationActiveis whatever the new option list carries, so a closure over a flag or timestamp that outlives the reload spans reloads; one rebuilt on each call does not.Prometheus. With
op.WithPrometheus, the outgoing provider's collectors are unregistered and the new ones registered on the same registry. A scrape between the two sees no series for the issuer, and the counters restart from zero;rate()treats that as a counter reset. If the reload is refused, the old collectors are registered again.Draining. After the swap,
Reloadwaits for the outgoing provider's in-flight back-channel logout deliveries before it returns, so a slow RP delaysReloadand the next one waits behind it. Bound the wait with the context. Requests already running on the outgoing provider finish on it.Never expose
Reloadon an HTTP path. It rebuilds the OP's security configuration; trigger it from a signal, a file watcher, or an administrative process the OP's clients cannot reach.
Source:
examples/53-production-sqlreads the signing and cookie keys from a key directory withop.LoadKeysetandop.LoadSymmetricKeys, serves throughop.NewReloadable, and reloads on SIGHUP. Rotating the cookie key there ismv cookie.key cookie-retiring.key, write a newcookie.key, thenkill -HUP.
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 akidfor a different key makes any RP that cached the old public key fail verification. 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. - No
key.retired_kid_presentedaudit events fire after the cut-over. The OP emits this event when a JWS / JWE names akidwhoseNotAfterhas passed, so it only applies to entries where you setNotAfter; silence is the success signal. - A browser session established before rotation can resume an authorization request at
/authwithout re-authentication (the session cookie still decrypts).
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.
op.Reloadable keeps that supervisor small: it serializes reloads, swaps the provider atomically, and refuses a change of issuer or routes, a loss of the running cookie, signing, or MFA keys, and a different DPoP nonce source.