Skip to content

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 with RS256) 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 as use=enc keys 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.

Rotating a key without a flag day
The shape of a signing or cookie key rotation: generate a new key, build a Provider with the new key first and the old one still present, swap the handler atomically, wait out the token lifetime and every cached key set, and only then drop the old key.Generatea new keywith a new kidPut it first[new, old]first signs, the rest verifyop.Newrevalidates the wholeconfiguration againSwap the handleratomic: no request seesa half-rotated providerWait it outthe token TTL and every RP'sJWKS cache windowDrop the old keynot before the wait endsonly then is it safe
For a routine rotation, the old key stays published for the whole overlap and only stops signing. Removing it early can break this: a verifier still holding the previous key set has no way to check a token it already accepted. A compromised key is an incident exception and must be removed promptly.

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 Keyset without 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:

go
// 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. /jwks is served with max-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.

How long the old key has to stay
A timeline that starts when the handler is swapped and the old key stops signing. Tokens the old key already signed stay valid for one token lifetime. RP caches of the previous key set can take up to two hours to turn over. The old key stays in the Keyset and in JWKS until the last of these has ended, and that moment is the earliest it may be dropped.after the swapthe old key stops signingTokens it signedvalid for one more token lifetime (5 min by default)RP key-set cachesmay still serve the previous set (up to 2 h by default)Old key in the Keysetkeep it published and trusted until the last bar above endsearliest drop
Bar lengths are schematic. The old key is safe to remove only when the longest bar has ended: with defaults that is the cache window, and with long-lived tokens the token bar can overtake it.

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.

  1. Generate an RSA key of 2048 bits or more, for example openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072.
  2. Put it first in the Keyset and keep the old EC key after it with a NotAfter. An entry whose type differs from the signing algorithm must carry one, or op.New fails.
  3. Add op.WithSigningAlgorithm(op.AlgRS256) and reload.
  4. After NotAfter, reload again with the RSA key alone.
go
// 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 ES256 reject every token the OP issues after the switch until they re-read discovery and accept RS256. Tell them before the reload.
  • RPs registered through Dynamic Client Registration with id_token_signed_response_alg=ES256 hold 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 a PUT of that unchanged metadata to the registration management endpoint is rejected with invalid_client_metadata. The RP must send id_token_signed_response_alg=RS256 (or omit it, in which case the OP fills in RS256). Registrations made after the switch default to RS256.
  • 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 alg when they verify. The OP's own verifiers already require the JWS header alg to 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.

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

Cadence ​

  • Routine: quarterly (low risk; the cookies use the __Host- prefix and their lifetime is bounded by the session lifetime).
  • Compromised: immediately.

Procedure ​

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

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

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

go
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. Reload takes every option, exactly as op.New does, 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 with errors.Is); change those by restarting the process. When the route set is what changed, the replacement had already been built, so new WithStaticClients seeds 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.WithCookieKeys and op.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 the Keyset until the tokens it signed have expired.

  • Key continuity is enforced. Reload refuses, with op.ErrReloadIncompatible, and the running provider keeps serving, when the new options would lose what the running provider handed out:

    • a WithCookieKeys list, or a WithKeyset, that does not contain the running active (first) key. A key with the same KeyID but different key material counts as missing;
    • an option list that drops any MFA encryption key the running configuration holds, whether set through WithMFAEncryptionKeys or through StepTOTP.EncryptionKey / EncryptionKeyPrev. Retire an MFA key by restarting once every TOTP record is re-sealed;
    • a WithDPoPNonceSource that 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.

  • 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.WithJWKSRotationActive is 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, Reload waits for the outgoing provider's in-flight back-channel logout deliveries before it returns, so a slow RP delays Reload and the next one waits behind it. Bound the wait with the context. Requests already running on the outgoing provider finish on it.

  • Never expose Reload on 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-sql reads the signing and cookie keys from a key directory with op.LoadKeyset and op.LoadSymmetricKeys, serves through op.NewReloadable, and reloads on SIGHUP. Rotating the cookie key there is mv cookie.key cookie-retiring.key, write a new cookie.key, then kill -HUP.

What you do not rotate ​

  • Issuer. Changing WithIssuer invalidates every issued token (iss is part of the JWT) and every RP's discovery cache. Treat as a deployment, not a rotation.
  • KeyIDs in your Keyset. Once advertised in JWKS, a kid is a public identifier RPs cache. Reusing a kid for a different key makes any RP that cached the old public key fail verification. Generate fresh kid values per rotation.

Verification checklist ​

After every rotation:

  1. curl https://op.example.com/jwks — confirm the new kid is present and the retired kid is still listed (unless you've completed the drop step).
  2. New token kid header points at the new key: echo "<jwt>" | cut -d. -f1 | base64 -d | jq .kid.
  3. No key.retired_kid_presented audit events fire after the cut-over. The OP emits this event when a JWS / JWE names a kid whose NotAfter has passed, so it only applies to entries where you set NotAfter; silence is the success signal.
  4. A browser session established before rotation can resume an authorization request at /auth without 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.