OpenE2EE

Build an encrypted Expo conversation from scratch

A complete Expo development build that establishes a Signal Protocol session, sends an encrypted message, and decrypts it locally, with every key custody decision stated.

Status
pre-1.0
Applies to
0.1.0
Platforms
Expo
Prereqs
Node 20+, an Expo project, and the ability to run a development build on a device or simulator
Reading time
16 min

There is no supported way to run the Signal Protocol in a React Native app in 2026 without fighting the runtime. Start with the facts because the documentation does not collect them in one place.

Hermes does not include WebCrypto. expo-crypto supports hashing, UUIDs, and random bytes, but not key agreement. It has no SubtleCrypto, ECDH, HKDF, X25519, or Ed25519. You cannot use it to build X3DH, and synchronous getRandomBytes has a 1024-byte limit.

The react-native-get-random-values workaround must be the first entry-file import or it does nothing. Expo previously broke this polyfill when it converted expo-random to JSI. An Expo core maintainer filed the issue, and expo-random is now deprecated.

The react-native-quick-crypto alternative does not run in Expo Go. It also conflicts with SQLCipher libraries on Android because both provide libcrypto.so. Issue #1059 opened in June 2026 and still tracks this conflict. This combination is common in E2EE apps with encrypted local databases.

Pure JavaScript cryptography can also be slow on Hermes. A wallet-creation benchmark measured 33.5 seconds on Hermes and 14.4 seconds on JSC on an iPhone 11 Pro.

The SDK uses pure TypeScript with no native cryptography module, prebuild step, or platform binary. The protocol uses @noble/*, with six direct production dependencies that resolve to six packages in total. It avoids the libcrypto.so conflict, JSI changes, and per-platform cryptography binaries. The phrase "no native anything in your app" would be inaccurate. The Expo storage adapter uses application-owned SQLCipher through expo-sqlite, so this guide requires a development build.

Randomness resolves in a documented order inside the SDK: globalThis.crypto.getRandomValues first, then Node's webcrypto, then expo-crypto.getRandomBytesAsync. The Expo fallback is asynchronous and is not subject to the 1024-byte cap on the synchronous call. You are not required to install react-native-get-random-values for the SDK's own randomness.

Intended audience

You ship a React Native app with Expo, you are comfortable with a development build and native config plugins, and you own a backend already. You are not looking for a hosted E2EE service: there is no OpenE2EE service to point at. You want to know exactly which bytes leave the device.

Prerequisites

  • A development build. The Expo store "requires a development build and is not available in Expo Go." That is the single most important expectation on this page. Everything below assumes npx expo run:ios / run:android or an EAS development build, not the Expo Go client.
  • Node 20 or newer, and an Expo app you can add native config to.
  • An application-owned SQLite database with SQLCipher enabled through expo-sqlite. You own its creation, migration, and lifecycle. The SDK composes tables into it.
  • Read keys, identity, and sessions, local-first architecture, and E2EE is not TLS first.

0.1.x; public APIs and persisted formats may change before 1.0.

What this guide builds

An Expo device holding private key material and an attached encrypted local store, separated by a trust boundary from a relay that carries a sealed envelope with visible metadataExpo deviceExpoSignalProtocolStore · SQLCipherrelaysealwrapping key · identityKeyPair private half
The vault-held wrapping key and identity private half stay on the device. The local store is not a service and is not remotely reachable. The sealed envelope crosses the boundary, where the relay can read its metadata.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite expo-crypto drizzle-orm

Adapters declare their runtime requirements as optional peer dependencies. The four above are what the Expo store and vault need. Add expo-device and react-native-device-info only if you later use DeviceLifecycleManager.

Stage 1: the database key, held in the vault

Two things are deliberately separate, and blurring them is the most common design mistake here.

The vault (ISignalProtocolLocalSecretVault) holds tiny bootstrap secrets: getSecret / setSecret / deleteSecret, nothing else. The store (ISignalProtocolLocalStore) holds the protocol state: identity keys, contact trust records, prekeys, sessions, sender keys, message records. The SDK states the reason for the split plainly: "Platform secret managers are appropriate for tiny keys and bootstrap values, but not full session databases."

So the vault holds one 32-byte database key. The store holds everything else, inside a database that key encrypts.

// signal/database.ts — application-owned
import * as SQLite from 'expo-sqlite';
import { drizzle } from 'drizzle-orm/expo-sqlite';
import { getDatabaseKeyManager } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import { configureSignalProtocolExpoDbBindings } from '@open-e2ee/signal-protocol-sdk/local/store/expo/db';
import * as signalSchema from '@open-e2ee/signal-protocol-sdk/local/store/expo/schema';

export async function bootstrapEncryptedDatabase() {
  const keyManager = getDatabaseKeyManager();
  await keyManager.initialize();            // generates the key once, then no-ops
  const sqlCipherPassword = await keyManager.getPassword();

  const rawDatabase = await SQLite.openDatabaseAsync('signal.db');
  // SQLCipher key application, per the Expo SQLite SQLCipher documentation.
  // getPassword() returns a full x'<hex>' literal so the key keeps 256 bits of entropy.
  await rawDatabase.execAsync(`PRAGMA key = ${sqlCipherPassword}`);

  const drizzleDatabase = drizzle(rawDatabase, { schema: signalSchema });
  // Your migrations create the exported tables. They are yours to version.

  configureSignalProtocolExpoDbBindings({
    getDrizzle: async () => drizzleDatabase,
    getRawDatabase: () => rawDatabase,
  });
}

getDatabaseKeyManager() defaults to ExpoSecureStoreSignalProtocolSecretVault, so on a stock Expo app you get the vault for free. Wire it explicitly when you want to see it or supply your own vault:

import { ExpoSecureStoreSignalProtocolSecretVault } from '@open-e2ee/signal-protocol-sdk/local/vault/expo-secure-store';

const vault = new ExpoSecureStoreSignalProtocolSecretVault();
await vault.setSecret('signal-store-wrapping-key', wrappingKey);
const restored = await vault.getSecret('signal-store-wrapping-key');

Secret names are application-wide storage keys. Namespace them, and delete them in the same account-reset transaction as the encrypted store.

Stage 2: the store and the client

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import { inMemoryRelay } from '@open-e2ee/signal-protocol-sdk/remote/relay/memory';

await bootstrapEncryptedDatabase();

const relay = inMemoryRelay(); // development only — replaced in the next guide
await relay.registerDevice(userId, { encryptedDeviceName: new ArrayBuffer(0) });

const signal = await createSignalProtocolClient({
  identity: { userId },
  adapters: { storage: expoStore({ relay }), relay },
});

inMemoryRelay() is an in-memory InMemorySignalProtocolRelayServer. It is development only, holds nothing across a reload, and does not authenticate callers or authorize operations. It exists so you can prove the protocol path before you own a backend. Connect an Expo app to a Convex encrypted-envelope relay replaces it with a real one.

Post-quantum policy defaults to postQuantum: 'required' and braid: 'required'. Sessions with peers that have no post-quantum material fail closed. 'compatible' is an explicit opt-in and does not allow downgrade recovery. Leave the defaults alone unless a reviewed product constraint forces otherwise.

Stage 3: publish public key material

await signal.syncToServer(({ stage, percent, message }) => {
  setSetupProgress({ stage, percent, message });
});

syncToServer() generates missing identity material and a batch of one-time prekeys. It uploads the public keys and signatures. The progress callback reports stage as generating-keys, generating-kyber, uploading, or complete. It also reports percent, message, and optional detail values for current and total. Show this progress because Kyber generation on a mid-range Android device can make a spinner appear stalled.

client.syncStatus afterwards is 'synced' | 'failed' | 'none'.

Stage 4: receive, then send

Register the hook before the subscription. startRelaySubscription() logs a warning and returns without subscribing if no onMessageDecrypted hook exists.

signal.registerHook('onMessageDecrypted', async (envelope) => {
  // Decrypted content reaches your app here, and nowhere else.
  await appDb.insertMessage({
    messageId: envelope.messageId,
    conversationId: envelope.conversationId,
    senderId: envelope.senderId,
    body: envelope.content,
    sentAt: envelope.timestamp,
  });
});

signal.startRelaySubscription();

const result = await signal.send('bob', 'hello');
// result.messageId, result.timestamp, result.recipientDeviceCount

The DecryptedEnvelope your hook receives carries messageId, sessionId, senderId, senderDeviceId, conversationId, content, timestamp, serverTimestamp, receivedAt, and isGroup. From that moment the plaintext is yours: "Your app owns decrypted message rows and local files after the Signal Protocol client decrypts or stages them." The SDK does not keep a copy of the plaintext for you.

Stage 5: two identities, one app, no backend

To test a round trip before you have a relay, pair the Expo client with an in-process peer. Use inMemoryStore() for the peer, not a second expoStore(). The Expo adapter uses module-level database bindings, so two instances in one process share one database and identity.

import { inMemoryStore } from '@open-e2ee/signal-protocol-sdk/local/store/memory';

await relay.registerDevice('bob', { encryptedDeviceName: new ArrayBuffer(0) });

const bob = await createSignalProtocolClient({
  identity: { userId: 'bob' },
  adapters: { storage: inMemoryStore(), relay },
});
await bob.syncToServer();

bob.registerHook('onMessageDecrypted', async (m) => console.log(m.senderId, m.content));
bob.startRelaySubscription();

await signal.send('bob', 'hello');

Both inMemoryStore() and inMemoryRelay() are development only. Neither belongs in a shipped build, and the store holds everything in memory, so nothing survives a reload.

Call signal.stop() when tearing down: it stops subscriptions and background work.

Trust boundaries

Three lines matter, and they are not the same line.

Device ↔ OS. The wrapping key uses expo-secure-store with keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY. The app can read it after the user first accesses the device, which supports background delivery. It is not eligible for iCloud Keychain sync. On Android, the platform keystore backs expo-secure-store. Sessions, prekeys, and message records remain in your app's SQLCipher database. A device compromise while the app is accessible exposes this data.

Device ↔ relay. The relay never needs message plaintext or device private keys. It does need public prekey bundles, device records, and envelopes with routing metadata. That is the line the dotted gutter marks in the diagram, and everything below the Opacity Ledger's "Sent to relay" column crosses it.

App ↔ SDK. "The client owns protocol coordination; the host application owns persistence, authentication, authorization, and product policy." Your database of decrypted messages, your auth, your retention rules.

What the backend can see

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Database key (signal_db_encryption_key, in the vault)yesnon/ano
identityKeyPair private halfyesnon/ano
identityKeyPair public half / CompositeIdentityV1yesyesn/ayes
EcSignedPreKey public half + signatureyesyesn/ayes
EcOneTimePreKey public halvesyesyesn/acount
KemOneTimePreKey public halves + signaturesyesyesn/acount
KemLastResortPreKey public halfyesyesn/ayes
All prekey private halvesyesnon/ano
SessionRecord (version: 4) and ratchet stateyesnon/ano
Message plaintext / envelope.contentyesnon/ano
Envelope ciphertextyesyesn/asize
targetUserId, targetDeviceId, senderDeviceIdyesyesn/ayes
messageType, timestamp, clientMessageIdyesyesn/ayes
encryptedDeviceNameyesyesn/aciphertext length
Plaintext device nameyesnon/ano
registrationIdyesyesn/ayes

Review questions

Where do the keys live? The 32-byte database key lives in expo-secure-store under signal_db_encryption_key, iOS Keychain with AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, Android keystore-backed. Everything else lives in your SQLCipher database via ExpoSignalProtocolStore. The device ID and a local identity public-key copy also sit in secure storage under signal_device_id and signal_identity_public_key.

How long is anything retained? Sessions persist until you call deleteSession, archiveSession, or clearAllData. MAX_UNACKNOWLEDGED_SESSION_AGE_MS is 30 days. Skipped message keys expire at keyExpirationMs: 7 days by default, capped by maxMessageKeysStored of 1000 and maxSkip of 1000. Prekeys age out at maxPreKeyAgeMs, 14 days by default.

How do I keep this away from my backend? The SDK never gives private keys to a network adapter. You must keep plaintext out of backend services. For example, Supabase can hold application rows, authentication, and profile data. Keep the relay separate and limit it to envelopes and public key material.

The SDK does not provide a Supabase adapter. Implement ISignalProtocolRelayServer from /remote/relay/types with the responsibilities in relay and prekeys. Do not store envelope.content in Supabase and describe the result as end-to-end encrypted.

How many prekeys, and when do they refill?

ONE_TIME_PREKEY_BATCH_SIZE generates 100 EC and Kyber one-time prekeys. The store can hold at most 200 EC prekeys. The product low-water mark, preKeyLowThreshold, defaults to 50. The internal replenishment threshold is 10.

keyRefreshIntervalMs defaults to 172800000 (2 days). maxPreKeyAgeMs defaults to 1209600000 (14 days). syncToServer() replenishes keys. checkPreKeyStatus() returns { oneTimePreKeysRemaining, needsReplenishment }. onPreKeyLow(remaining) fires at your configured threshold.

What happens on reinstall? iOS Keychain items can survive an uninstall, but the app sandbox and SQLCipher database do not. The SDK therefore writes a .device-owner sentinel into the sandbox. "A missing ownership sentinel means retained secure-storage data must not be trusted as belonging to the current installation."

DeviceLifecycleManager from /device/lifecycle models reclaim_reinstall, key_mismatch, and orphaned_linked_device states. It provides handleReinstallReclaim() and handleKeyMismatchReset() transitions. Its DeviceLifecycleApi is Convex-shaped, so connect it in the relay guide. Without recovery, a reinstall creates a new identity key and every peer sees an identity change. See recovery, backup, migration.

What does the relay store? Public prekey bundles, device records with encrypted device names, provisioning sessions, and envelopes: ciphertext plus routing metadata. Not plaintext, not private keys.

What about backups? The SDK has no automatic backup. AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY deliberately keeps the wrapping key out of iCloud Keychain, and a device-to-device path exists via /device (prepareNewDeviceTransfer(), prepareOldDeviceTransferWithBackup()). Provisioning a linked device transfers identity, not sessions or history: these are two distinct operations on purpose.

Failure and recovery behaviour

Production caveats

  • The Expo store is the primary supported adapter, and it still requires a development build. If your distribution plan depends on Expo Go, this SDK does not fit it.
  • We publish the package on npm as 0.1.x: AGPL-3.0-or-later, reviewed continuously by adversarial AI agents, and not audited by any independent firm. Public APIs and persisted formats may change before 1.0.
  • The SDK requires 0x0A || raw ML-KEM-1024 bytes. This encoding "is distinct from Signal Messenger deployments that use round-3 Kyber1024 tagged 0x08." The projects are not wire-compatible or affiliated.
  • Pure-JS crypto is not FIPS 140-validated. The phrase "PQXDH uses standardized FIPS 203 ML-KEM-1024 behavior" is about algorithm behaviour, not validation.
  • Assurance today: 384 modules, 6,893 assertions, 2 skipped, 0 failed, 330 s, with public CI running npm ci, build, typecheck, and npm audit --omit=dev.
  • Plan verification UX carefully. One study found that 21 of 28 CS students could not verify a public key. Only 13% completed the ceremony after researchers explained the risks. See identity change and safety numbers.

Next

On this page