Engineering · 2026-11-05
Key Rotation Without Verification Gaps: Overlapping Validity Windows
By J. W. Bouckaert
Two periods for one key pair
NIST SP 800-57 Part 1 Rev. 5 gives each half of a signing key pair its own cryptoperiod. The private key has an originator-usage period, during which it may create signatures. The public key has a recipient-usage period, during which those signatures may be verified. The two "typically begin at the same time, but the recipient-usage period may extend beyond the originator-usage period."
That sentence is the whole design problem of rotation. A signature made on the last day of the private key's life still has to verify after the key is retired. If the verifier drops the public key when the signer stops using the private key, every assertion still in flight fails at once.
The same document puts numbers on the two periods. For a private signature key it recommends "a maximum cryptoperiod of about one to three years" and says the key "shall be destroyed at the end of its cryptoperiod". For the public verification key it says the cryptoperiod "may be on the order of several years", because it is "the period during which any signature computed using the corresponding private signature key needs to be verified."
The rotation choreography in OpenID Connect
OpenID Connect Core 1.0, section 10.1.1, writes the same idea as a protocol. The signer publishes its keys in a JWK Set at its jwks_uri and puts the kid of the signing key in the JOSE header of each message. New keys are added to the set before use. The verifier "knows to go back to the jwks_uri location to re-retrieve the keys when it sees an unfamiliar kid value." And the set "SHOULD retain recently decommissioned signing keys for a reasonable period of time to facilitate a smooth transition."
RFC 7517 defines what kid is for: it "is used to match a specific key", for instance "to choose among a set of keys within a JWK Set during key rollover." Its structure is unspecified, and distinct keys in one set "SHOULD use distinct kid values."
Put together, a rotation without a verification gap has four steps in a fixed order.
- Publish the new public key beside the current one. Nothing signs with it yet.
- Wait at least one cache lifetime of every verifier that fetches the key set, so all of them have seen the new key.
- Switch signing to the new private key. Every new message carries the new
kid. - Keep the old public key published until the longest-lived assertion signed under it has expired, then remove it.
Step 2 and step 4 are the two overlap windows. The first protects verifiers from a kid they have never seen. The second protects assertions signed before the switch.
Sizing the second window
The assertions set the second window. Its minimum length is the longest validity of anything the old key signed, plus the clock skew you accept, plus the time a verifier may hold a cached key set. A token with a five-minute lifetime needs minutes of overlap. A credential valid for a year needs a year, or a re-issue under the new key before the old one leaves the set.
That is why long-lived assertions and short rotation intervals pull against each other. Three ways out are common:
| Approach | What it costs | Where it fits |
|---|---|---|
| Long overlap: keep old public keys published for the credential lifetime | A larger key set; verifiers try more keys | Credentials with a known expiry |
| Re-issue before retirement | An issuance pass per rotation | Short-lived tokens, renewable credentials |
| Verification through a status or resolution step that knows key history | A dependency on that service at verify time | Closed ecosystems |
Failure modes
- Signing before publishing. A verifier sees an unknown
kid, refetches, and the new key is not there yet. Every request in that window fails. - Retiring with the switch. The old public key leaves the set at the same moment signing moves. Every in-flight assertion fails.
- No
kid, or a reused one. The verifier has to try every key, or picks the wrong one and reports a valid signature as invalid. - A cache longer than the overlap. A verifier holding the set for a day will not see a key published an hour before the switch.
Tenant signing key rotation in PasskeyBridge
Each tenant has one active EC signing key, P-256 (ES256) by default or P-384 (ES384), platform-generated or imported by the tenant. The key's kid is part of its published JWK. The rotation code makes these choices:
- The replacement is built first. The new key is generated or imported and encrypted before anything else changes, so bad import material leaves the current key untouched.
- The swap is one statement. Retiring the current key and inserting the new one happen in a single database transaction under a per-tenant lock. A concurrent reader sees the old key active or the new one, never neither. A rotation whose view of the current key has gone stale is refused and changes nothing.
- The old key enters a 24-hour grace period. It is marked inactive with a
grace_expires_attimestamp 24 hours out. The rotation policy defaults are a 720-hour interval and a 24-hour grace. - Our own verification uses both. When PasskeyBridge checks a presentation of a credential the tenant issued, it tries the active key and every key still inside its grace period.
One gap follows from that design, and we state it because it is the subject of this post. The tenant DID document lists only the active EC key. A relying party that verifies a PasskeyBridge credential by resolving the issuer DID itself, rather than through our verification endpoint, will not find the retired key after the swap. For that verifier, the overlap window of step 4 is zero. Until the DID document carries grace-period keys, the safe pattern is to verify through the endpoint, or to re-issue credentials before a rotation.
The post-quantum half does not rotate on this schedule. A credential's detached ML-DSA proof is verified against the key the tenant DID document publishes at #mldsa-1. That key is derived from the tenant id under a fixed derivation version, and an EC rotation does not change it, so the ML-DSA layer of credentials issued before the rotation keeps verifying.
The full key lifecycle, including import and the rotation call, is in the key management guide. The hybrid signing design is covered in our earlier post on dual signing, and the wire-format side of algorithm changes in cryptographic agility in protocol wire formats.
A checklist for your next rotation
- Every signed message carries a
kid, and every key in the set has a distinct one. - The new public key is published at least one verifier cache lifetime before first use.
- The old public key stays published for the longest assertion lifetime plus skew plus cache lifetime.
- Verifiers refetch the key set on an unknown
kid, with a rate limit on refetches. - Every verification path is listed, including relying parties that resolve keys themselves, and each one has an overlap window above zero.