OpenE2EE

Choosing adapters

The four adapter slots, which are required, the honest maturity of each shipped option, and how to decide.

Status
pre-1.0
Applies to
0.1.0
Platforms
All
Prereqs
Quickstart
Reading time
8 min

The SDK is a protocol core with four pluggable slots. Composition is the API: what you pass here determines where keys live, who can deliver messages, and what your backend can see. Adapters are security boundaries, not convenience callbacks.

const signal = await createSignalProtocolClient({
  identity: { userId, deviceId, enablePniKeys },
  adapters: {
    storage,           // required
    relay,             // optional
    remoteObjectStore, // optional
    protocolManager,   // advanced
  },
  protocol: { /* ... */ },
});

Note the shape: only those four live under adapters. Everything else, logger, hooks, sealedSender, groupsV2, prekey timings, is a top-level field on the config.

The four slots

SlotInterfaceRequiredWhat it owns
storageISignalProtocolLocalStoreyesDevice-local protocol state: identity keys, prekeys, sessions, ratchet state, contact trust
relayISignalProtocolRelayServernoPublic key distribution, device registry, envelope delivery
remoteObjectStoreSignalProtocolRemoteObjectStorenoBrokered upload and download of encrypted attachment bytes
protocolManagerISignalProtocolManagernoAdvanced override, mainly for tests

The design requires only storage. You can replace delivery or omit the relay. The local test path still encrypts and decrypts. You cannot omit the store because it holds ratchet state. If you lose the store, no server copy can recover it.

Storage adapters

npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite drizzle-orm
AdapterSubpathExportsStatus
Expo/local/store/expoExpoSignalProtocolStore, expoStore()Primary. Requires a development build; SQLCipher is not available in Expo Go.
Node/local/store/nodeNodeSignalProtocolStore, nodeStore()Supported. Encrypted filesystem storage; full store contract.
Web/local/store/webIndexedDbSignalProtocolStore, indexedDbStore()Supported. Full store contract; its graduation gates run in real Chromium, Firefox, and WebKit on every change. Deployment requires the browser threat model review.
React Native (bare)/local/store/react-nativereactNativeStore()Supported. Full store contract; you supply the key-value backend and verify it with the exported backend-conformance kit.
In-memory/local/store/memoryInMemorySignalProtocolStore, inMemoryStore()Development only. Everything is lost on restart.

Every store carries the same contract

Each shipped store declares implements ISignalProtocolLocalStore and carries the full contract. This includes Expo, Web, React Native, in-memory, and Node. The contract includes the Sesame device store, sender-key store, and message-record store. Multi-device and both group APIs therefore work on every store, including Node.

Factory call shapes differ, and this catches people porting between runtimes:

expoStore(options?)              // synchronous
inMemoryStore()                  // synchronous
await indexedDbStore()           // async
await nodeStore(config?)         // async
await reactNativeStore(options)  // async

There is a fifth piece that is not a store: the secret vault, ISignalProtocolLocalSecretVault, with ExpoSecureStoreSignalProtocolSecretVault at /local/vault/expo-secure-store. It holds small bootstrap secrets, on Expo, exactly one 32-byte database key, in the OS keychain. Platform secret managers are appropriate for tiny keys and bootstrap values, not full session databases. Do not try to put protocol state in it.

Relay adapters

AdapterSubpathExportsStatus
Convex/remote/relay/convexConvexSignalProtocolRelayServer, convexRelay()Usable. You own the deployment, schema, auth, and authorization.
In-memory/remote/relay/memoryInMemorySignalProtocolRelayServer, inMemoryRelay()Development only. No auth, no persistence, no authorization.

Omitting relay gives you local-only mode: real encryption and decryption, no distribution. That is a legitimate configuration for tests and for a first integration where you want the protocol working before the backend exists.

If neither adapter fits, implement ISignalProtocolRelayServer. Read reference → adapters first. The relay must preserve one-time-prekey consumption during concurrent requests. It must never issue a consumed prekey as unused.

Object store adapters

AdapterSubpathExports
Convex R2/remote/object-store/convex-r2ConvexR2ObjectStore, convexR2ObjectStore()
Convex R2 server helper/remote/object-store/convex-r2/serverdefineConvexR2ObjectStore()
S3/remote/object-store/s3S3ObjectStore, s3ObjectStore()

Both are brokered, and this is not an implementation detail. An authenticated application backend maps a request to a canonical object ID and a private provider key, and issues scoped URLs. Cloud credentials and unrestricted provider clients do not belong in the app runtime. If you choose an adapter that takes an access key in the client, you choose the wrong adapter.

Omit remoteObjectStore and file upload operations throw. See encrypted attachments.

How to decide

Start from your runtime, because storage is the only required slot and it is the most constrained choice:

  • Shipping an Expo app → use the Expo store and secure-store vault. Plan for a development build.
  • Shipping a web app → use the IndexedDB store. Evaluate the browser threat model separately from the adapter.
  • Shipping a bare React Native app → use the React Native store. You supply the key-value backend; verify it with the exported backend-conformance kit before you ship.
  • Building a service, CLI, or test → the Node store, having read why a server-side identity is a real participant.

Then decide the relay, which is a backend question rather than a runtime one. Already on Convex: use the adapter. On something else: Supabase, your own API, anything: implement ISignalProtocolRelayServer. It is a bounded interface, and the SDK never asks it for anything private.

Then decide attachments, which most teams should defer. Get messaging working first.

Next

On this page