OpenE2EE

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 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, from your own tests, with the reopen hook wired so durability is proven rather than skipped. 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 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_LOCKED and KEY_STORAGE_ERROR explicitly 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 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. MESSAGE_DUPLICATE is a normal outcome, not an incident.

See relay and prekeys.

5. Identity change has a real UX

  • IdentityKeyChangedError reaches 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.
  • 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(), isIdentityKeyChangedError(), isSessionConflictError(), isRegistrationIdChangedError()) rather than instanceof across bundle boundaries.
  • 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

  • Ask someone with authority to choose between AGPL-3.0-or-later and a commercial license. Record that choice. The AGPL obligations apply to network-facing use. See licensing and buying a commercial license.

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