Key rotation
The five rotation cadences, their exact default intervals, and which ones the SDK performs for you.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 11 min
There is no single thing called "key rotation" in this system. There are five kinds of key with five different lifetimes, five different triggers, and five different failure modes when they go stale. Conflating them produces either a client that never rotates anything or one that rotates identity keys on a timer, which is considerably worse.
The five cadences
| Key | Trigger | Who performs it |
|---|---|---|
| One-time prekeys | Consumption by peers | syncToServer() replenishes |
| EC signed prekey | Time interval | rotateEcSignedPreKey(), gated by interval |
| Kyber prekey | Time interval | rotateKyberPreKey(), gated by interval |
| Identity keys | Explicit trust event | rotateAccountIdentity(...) only |
| Group sender keys | Membership change | rotateGroupSenderKey(groupId) |
The exact numbers
Use these. Do not derive intervals from anything else.
| Constant | Default | Meaning |
|---|---|---|
keyRefreshIntervalMs | 172800000 (2 days) | Signed and Kyber prekey refresh interval |
maxPreKeyAgeMs | 1209600000 (14 days) | Hard age ceiling; a safety buffer above the refresh interval |
preKeyCheckThrottleMs | 43200000 (12 hours) | Minimum spacing between prekey status checks |
ONE_TIME_PREKEY_BATCH_SIZE | 100 | Batch uploaded per replenishment, EC and Kyber alike |
MIN_PREKEY_REPLENISHMENT_THRESHOLD | 10 | Internal floor that triggers replenishment |
preKeyLowThreshold | 50 | Product-facing low-watermark for onPreKeyLow |
MAX_EC_PREKEYS | 200 | Ceiling on stored EC prekeys |
MAX_UNACKNOWLEDGED_SESSION_AGE_MS | 30 days | Age at which an unacknowledged session is stale |
keyExpirationMs | 604800000 (7 days) | Double Ratchet message-key retention |
The 10 and the 50 have different purposes. MIN_PREKEY_REPLENISHMENT_THRESHOLD is the internal floor at which the SDK replenishes keys. preKeyLowThreshold controls when the onPreKeyLow callback fires. Your product can then update a dashboard, alert, or force a sync before the internal floor. Setting preKeyLowThreshold near 10 removes this warning margin.
You may encounter an older cadence in a source docstring on the rotation methods. The keyRefreshIntervalMs default of 172800000 ms is authoritative.
One-time prekeys: consumed, then replenished
One-time prekeys exist for peers to use up. Each peer that establishes a session with you consumes one from your published bundle. A timer does not rotate them. Other people's behaviour drains them at a rate you do not control.
syncToServer() is the operation that replenishes. Nothing else does.
await client.syncToServer();
const status = await client.checkPreKeyStatus();
// status.oneTimePreKeysRemaining: number
// status.needsReplenishment : booleanClient creation calls syncToServer() when you configure a relay. The SDK does not call it again for you. Call it when the app enters the foreground. Also call it after a relay outage or an operation that consumed keys. preKeyCheckThrottleMs (12 hours) throttles checkPreKeyStatus() internally. More frequent calls do not return newer information.
Wire the low callback at composition time:
const client = await createSignalProtocolClient({
identity: { userId },
adapters: { storage, relay },
preKeyLowThreshold: 50,
onPreKeyLow: (remaining) => {
metrics.gauge('signal.prekeys.remaining', remaining);
void client.syncToServer();
},
});Signed prekeys and Kyber prekeys: interval-driven
rotateEcSignedPreKey() and rotateKyberPreKey() each return a boolean. They return true after they rotate a key. They return false before rotation is due. keyRefreshIntervalMs gates both functions internally, so calling them more often than every two days is inexpensive and does nothing.
That gating is the reason the recommended pattern is to call them unconditionally on a schedule you own, rather than computing due-ness yourself:
import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';
async function maintainKeys(client) {
await withRetry(() => client.rotateEcSignedPreKey(), {
operationName: 'rotateEcSignedPreKey',
maxRetries: 2,
baseDelay: 2000,
maxDelay: 30000,
});
await withRetry(() => client.rotateKyberPreKey(), {
operationName: 'rotateKyberPreKey',
maxRetries: 2,
baseDelay: 2000,
maxDelay: 30000,
});
await client.syncToServer();
}The application owns the schedule. The SDK does not run a timer, does not register a background task, and does not wake your app. On a mobile client the practical trigger is app foreground. On a long-running Node process it is an interval. A client that is offline for three weeks will hold prekeys past maxPreKeyAgeMs (14 days) until it next runs this path.
getSessionHealth(userId) reports keyStatus.signedPreKeyAgeDays, keyStatus.kyberPreKeyAgeDays, and keyStatus.needsRotation. Those are the fields to graph across your population. A rising p95 on signedPreKeyAgeDays means your scheduling trigger is not firing for a meaningful cohort.
cleanupExpiredKeys(remoteAddress) removes expired message keys for a session. The Signal Protocol specification recommends deleting message keys older than one week to bound storage, which matches the keyExpirationMs default of 604800000. This is a per-session operation and returns a boolean.
Identity keys: the expensive one
Identity rotation is not maintenance. It is a trust event.
Every peer pins your exact composite-identity tuple. "Replacing either component of a pinned tuple fails closed until rotation is explicitly accepted. A retired tuple cannot silently regain trust." Identity rotation makes each peer raise IdentityKeyChangedError. It also invalidates each safety number that both parties compared. A user who reached VERIFIED must repeat the ceremony.
rotateAccountIdentity(expectedCurrentCommitment, identityType?) rotates the identity. It uses a caller-authenticated compare-and-swap commitment, then publishes fresh prekeys for that namespace. Normal sync and linked-device provisioning never call it.
On the receiving side, a peer accepts a rotation with acceptIdentityRotation(userId, identity, identityType?). That call must be downstream of a human decision, never of an error handler. See identity change and safety numbers.
Group sender keys: membership-driven
Sender keys rotate when membership changes. The rotation removes a departed member's ability to read. removeGroupMemberV2(groupId, editorAci, targetAci) triggers sender-key rotation. rotateGroupSenderKey(groupId) explicitly rotates the key and returns { senderKeyId, distributionMessage }. Distribute the result with distributeGroupSenderKey(groupId, memberUserIds) or distributeSenderKeyToUser(groupId, recipientUserId). handleGroupMembershipChange(groupId, change) returns { rotated, distributionMessage? } so you can assert that rotation occurred.
Verify it rather than assuming it: getGroupSenderKeyStats(groupId, senderId, senderDeviceId) returns generation, and generation advancing is your evidence. See deletion and device revocation and groups.
What forceCompleteKeyReset() is for
It deletes sessions and prekeys and regenerates key material, returning a ForceKeyResetResult with deletedSessions and a deletedPreKeys breakdown (ecSignedPreKeys, ecOneTimePreKeys, kyberPreKeys, kemOneTimePreKeys). The SDK documents it as a development and debugging operation. It is a blunt instrument that resets every conversation on the device. If a production recovery path uses it, that path lacks a more specific operation.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
identityKeyPair private half | yes | no | no | no |
identityKeyPair public half | yes | yes | no | yes |
EcSignedPreKey private half | yes | no | no | no |
EcSignedPreKey public half + signature | yes | yes | no | yes, with key ID |
KyberPreKey private half | yes | no | no | no |
KyberPreKey public half | yes | yes | no | yes, 0x0A-tagged |
EcOneTimePreKey public halves (batch of 100) | yes | yes | no | count is inferable |
| Sender key (group) | yes | no | no | generation only, locally |
oneTimePreKeysRemaining | yes | no | no | inferable from fetch volume |
Session record (version: 4) | yes | no | no | no |
Next
Observability without plaintext
What you can safely measure in an encrypted system, what you must never emit, and why the obvious debugging fix is itself a vulnerability.
Deletion and device revocation
What deletion can and cannot mean in an encrypted system, and the lifecycle operations that actually revoke access.