Production readiness checklist
The concrete decisions and wiring an encrypted application needs before it carries real user data.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 11 min
This is a checklist of things that are wrong in most first integrations. Each item is a decision you make once and write down, or a piece of wiring that either exists or does not. Nothing here is aspirational.
Maturity, stated plainly before you rely on any of it: 0.1.x. Public APIs and persisted formats may change before 1.0. The SDK is reviewed continuously by adversarial AI agents; it is not audited by any independent firm.
1. Adapters
The adapter you ship decides how much of the checklist below is your problem.
- Choose a storage adapter whose documentation claims support for your platform. The documentation defines
ExpoSignalProtocolStore(/local/store/expo) as the primary supported adapter. It supportsNodeSignalProtocolStore(/local/store/node), which uses the filesystem, andIndexedDbSignalProtocolStore(/local/store/web), whose deployment requires the origin-security review in browser setup. - If you ship
ReactNativeSignalProtocolStore(/local/store/react-native), run the SDK's exported backend-conformance kit,assertBackendConformance, against the key-value backend you supply, from your own tests, with thereopenhook wired so durability is proven rather than skipped. The store's guarantees hold only over a backend that passes. -
InMemorySignalProtocolStore(/local/store/memory) andInMemorySignalProtocolRelayServer(/remote/relay/memory) appear in no production code path. They are development only, in-memory, and hold nothing across a restart. Add a build-time check that neither subpath is reachable from your release bundle. - If you use the Expo store, your build pipeline produces a development build. The Expo store "requires a development build and is not available in Expo Go."
-
ExpoSecureStoreSignalProtocolSecretVault(/local/vault/expo-secure-store) holds only a small bootstrap secret: a storage-wrapping key such as"signal-store-wrapping-key". "Platform secret managers are appropriate for tiny keys and bootstrap values, but not full session databases." - If you use an object store, access it through a broker. Keep cloud credentials and unrestricted provider clients on your backend, never in the app runtime.
See choosing adapters and the adapter reference.
2. Storage durability and atomicity
A decrypt mutates ratchet state. A partial or interleaved write corrupts it, and a retry cannot repair that state.
- Write session state atomically in your adapter's backing store. If you wrote a custom
ISignalProtocolLocalStore, test a process kill mid-write. - Two clients for the same
(userId, deviceId)never run concurrently against the same storage. One store instance owns one user on one device. - You handle
DATABASE_LOCKEDandKEY_STORAGE_ERRORexplicitly rather than swallowing them. A locked database usually means a wrong or missing encryption key, not a transient fault. - You have a plan for a store that will not open at all. The user-visible outcome is a device that cannot decrypt its own history. Decide now whether that is a re-provision flow or a reset flow.
3. Prekey replenishment
- Call
syncToServer()on app start, on foreground, and after any relay outage. Only this method replenishes prekeys. - Monitor
checkPreKeyStatus()throughout the application lifecycle, not only at boot. It returnsoneTimePreKeysRemainingandneedsReplenishment. - You set
preKeyLowThreshold(default50) and anonPreKeyLowcallback deliberately. The one-time prekey batch is100. The internal replenishment threshold is10. Do not conflate the two. - Your alerting fires on
needsReplenishmentbeing true for longer than one sync interval, not on a single reading.
const client = await createSignalProtocolClient({
identity: { userId },
adapters: { storage, relay },
preKeyLowThreshold: 50,
onPreKeyLow: (remaining) => metrics.gauge('signal.prekeys.remaining', remaining),
});
await client.syncToServer();4. Relay semantics under concurrency
- Your relay consumes one-time prekeys atomically. "the relay must not hand out the same one-time prekey as if it were still unused." Two concurrent bundle fetches for the same recipient must not receive the same one-time prekey.
- The relay allocates linked-device IDs. "the server owns linked-device slot allocation; clients must not choose linked
deviceIds." Primary is1. Linked devices are2through5. - Backend authentication binds registration, provisioning, unlink, and removal to the owning account.
- Envelope delivery is at-least-once and you tolerate duplicates.
MESSAGE_DUPLICATEis a normal outcome, not an incident.
See relay and prekeys.
5. Identity change has a real UX
-
IdentityKeyChangedErrorreaches a screen a user can act on, carrying.changedAddress,.oldIdentityKey, and.newIdentityKey. - The choice is explicit. Trust starts at
UNVERIFIED_TOFU.acceptIdentityRotation(...)is a deliberate act, never an automatic recovery step. - Safety-number comparison is reachable:
verify(remoteUserId)produces the comparison data,confirmSafetyNumber(confirmation)records the decision. The SDK creates comparison data. Your app owns QR rendering, scanning, the verification experience, and storage of the user's trust decision. - Watch a real user attempt the ceremony. In one study, 21 of 28 computer-science students could not verify a public key. Only 13% completed the ceremony when researchers explained the risks.
See identity change and safety numbers.
6. Recovery policy chosen and written down
- Pick a named recovery profile and record it where product and support can both read it. Recovery, backup and migration describes the profiles.
- Support has a written answer to "I lost my phone and I want my messages back." The answer follows from the selected profile. Support does not improvise it during a ticket.
- Provisioning and transfer are not confused in your code or your copy. Provisioning adds a linked device and transfers identity: not sessions or history. Transfer migrates local crypto state to replacement hardware via encrypted backup.
7. Backup posture
- Encrypt backup material before it reaches any transport. "Identity and backup material is encrypted before it reaches a relay or transport."
- You know what your platform backs up without asking. Include OS-level backups of your app's data directory in the threat model.
- Decrypted message rows are yours: "Your app owns decrypted message rows and local files after the Signal Protocol client decrypts or stages them." Their backup posture is a separate decision from the protocol store's.
8. Device unlink and account reset
- Account reset clears the device-ID cache, platform secret storage, and the protocol store as one lifecycle. Clearing two of the three leaves a device that believes it has an identity it can no longer use.
- Connect
clearAllData()to that lifecycle. CleargetDeviceId()/preloadDeviceId()state from/device/device-idin the same lifecycle. - Authenticate unlink requests at the backend against the owning account. Do not trust client assertions.
See deletion and device revocation and device lifecycle.
9. Error taxonomy handled
- Every
EncryptionErrorCodeyou can receive maps to a handler or an explicit "log and drop". The full taxonomy is at error handling and retries. - You use the exported type guards (
isUntrustedIdentityError(),isIdentityKeyChangedError(),isSessionConflictError(),isRegistrationIdChangedError()) rather thaninstanceofacross bundle boundaries. - Retry only retryable failures. Retrying a decrypt does not help and can leave ratchet state worse.
10. Observability without plaintext
- Connect
client.loggerto telemetry through a redaction boundary that your team reviewed line by line. - No plaintext, key material, or safety number reaches a log sink. See observability for what is safe to emit.
-
getStats(),getSessionHealth(userId), andcheckPreKeyStatus()feed dashboards, not only ad-hoc debugging.
11. Test failure paths
- Test out-of-order and skipped delivery up to
maxSkip(1000), prekey exhaustion, identity changes, and session recovery. Also test offline reconciliation, multi-device fanout, group removal, and attachment round trips. See testing encrypted flows.
12. Licensing decision
- Ask someone with authority to choose between
AGPL-3.0-or-laterand a commercial license. Record that choice. The AGPL obligations apply to network-facing use. See licensing and buying a commercial license.
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 public half | yes | yes | no | yes |
EcOneTimePreKey public half | yes | yes | no | yes |
Session record (version: 4) | yes | no | no | no |
| Message plaintext | yes | no | no | no |
| Message ciphertext | yes | yes | no | envelope size, timing |
| Encrypted attachment bytes | staged locally | no | yes | object size |
oneTimePreKeysRemaining | yes | no | no | inferable from bundle fetches |
Next
Operate
Running an encrypted application in production — what you can measure without plaintext, what rotates on its own, what fails silently, and what a security reviewer will ask for.
Error handling and retries
The full error taxonomy, which failures are retryable, and how to recover a session without making ratchet state worse.