OpenE2EE

Production readiness checklist

The concrete decisions and wiring an encrypted application needs before it carries real user data.

Status
stable
Applies to
6.0.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: 6.0.x. Public APIs and persisted formats follow semantic versioning. 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 supports NodeSignalProtocolStore (/local/store/node), which uses the filesystem, and IndexedDbSignalProtocolStore (/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. Run it from your tests and wire the reopen hook so the check proves durability. The store's guarantees hold only over a backend that passes.
  • InMemorySignalProtocolStore (/local/store/memory) and InMemorySignalProtocolRelayServer (/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 SignalProtocolLocalStore, 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 KEY_STORAGE_ERROR and STORAGE_QUOTA_EXCEEDED explicitly rather than swallowing them. Inspect a key-storage failure's cause. Free space before retrying a quota failure.
  • 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. syncToServer() and rotatePreKeys() are the only methods that replenish prekeys.
  • Monitor checkPreKeyStatus() throughout the application lifecycle, not only at boot. It returns oneTimePreKeysRemaining and needsReplenishment.
  • You set preKeyLowThreshold (default 50) and an onPreKeyLow callback deliberately. The one-time prekey batch is 100. The internal replenishment threshold is 10. Do not conflate the two.
  • Your alerting fires on needsReplenishment being 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 is 1. Linked devices are 2 through 5.
  • Backend authentication binds registration, provisioning, unlink, and removal to the owning account.
  • Envelope delivery is at-least-once and you tolerate duplicates. For an at-least-once redelivery, MESSAGE_DUPLICATE is a normal outcome, not an incident.
  • A custom relay adapter implements relayConnectionState and subscribeRelayConnectionState.
  • A React Native app binds the relay subscription to the app state with useRelayLifecycle or bindRelayLifecycle. It sets keepOpenInBackground only while it must receive in the background, for example during an Android foreground service.
  • A web app does not stop the relay subscription when a tab is hidden.

See relay and prekeys.

5. Identity change has a real UX

  • UntrustedIdentityError reaches a screen a user can act on, carrying .untrustedAddress and .identity.
  • 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.
  • 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. Clear getDeviceId() / preloadDeviceId() state from /device/device-id in 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 EncryptionErrorCode you 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(), isDuplicatedMessageError(), isSealedSenderAuthError(), isPQXDHRequiredError(), isStorageQuotaExceededError()) and ship one SDK copy. The current guards delegate to instanceof.
  • Retry only retryable failures. Retrying a decrypt does not help and can leave ratchet state worse.

10. Observability without plaintext

  • Connect client.logger to 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), and checkPreKeyStatus() 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

  • Record which of the two SDK licenses your product uses, MIT or Apache-2.0. Both permit proprietary use. Keep the license notice in your distribution. See licensing.

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair private halfyesnonono
identityKeyPair public halfyesyesnoyes
EcSignedPreKey public halfyesyesnoyes
EcOneTimePreKey public halfyesyesnoyes
Session record (version: 4)yesnonono
Message plaintextyesnonono
Message ciphertextyesyesnoenvelope size, timing
Encrypted attachment bytesstaged locallynoyesobject size
oneTimePreKeysRemainingyesnonoinferable from bundle fetches

Next

On this page