Expo
Set up the Expo storage adapter and secure-store vault, and get direct answers on key storage, retention, prekeys, reinstall, and backups.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo (development build) · React Native
- Prereqs
- Quickstart, plus an Expo project you can make a development build of
- Reading time
- 12 min
Expo is the primary runtime for this SDK. Other options do not support a complete Signal Protocol implementation in React Native. Hermes ships without WebCrypto, and expo-crypto does not support key agreement. Native modules either fail in Expo Go or conflict with the encrypted database libraries that an E2EE app needs. This implementation uses pure TypeScript, so the cryptography needs no native module or prebuild step.
This requires a development build
The Expo store keeps protocol state in an application-owned SQLCipher database. The SDK documentation is explicit: "SQLCipher requires a development build and is not available in Expo Go." Plan to use npx expo run:ios, run:android, or an EAS development build from the start. This requirement is the most common surprise on this page.
Install
npm install @open-e2ee/signal-protocol-sdk@0.1.0 expo-secure-store expo-sqlite drizzle-ormTwo storage boundaries, never blurred
The Expo setup has two distinct places secrets live, and conflating them is the most consequential mistake you can make here.
| Holds | Size | Facility | |
|---|---|---|---|
| Vault | One database encryption key | 32 bytes | expo-secure-store → iOS Keychain / Android Keystore |
| Store | Identity keys, prekeys, sessions, ratchet state, contact trust, message records | Kilobytes to megabytes | SQLCipher-encrypted SQLite |
Platform secret managers hold tiny bootstrap values, not a session database that changes on every message. So the vault holds exactly one thing, the key that unlocks the database, and the database holds everything else.
Bootstrap
The application owns database creation, so it can compose the SDK's tables into its own schema and transaction lifecycle. Do this once at startup, before creating the client.
import { configureSignalProtocolExpoDbBindings } from '@open-e2ee/signal-protocol-sdk/local/store/expo/db';
import { getDatabaseKeyManager } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import * as signalSchema from '@open-e2ee/signal-protocol-sdk/local/store/expo/schema';
const keyManager = getDatabaseKeyManager();
await keyManager.initialize();
const sqlCipherPassword = await keyManager.getPassword();
// Application-owned. Must enable SQLCipher through the expo-sqlite native
// configuration, apply the key before any schema access, create or migrate the
// exported tables, and return matching raw and Drizzle handles.
const { rawDatabase, drizzleDatabase } = await appDatabase.openEncryptedDatabase({
password: sqlCipherPassword,
schema: signalSchema,
});
configureSignalProtocolExpoDbBindings({
getDrizzle: async () => drizzleDatabase,
getRawDatabase: () => rawDatabase,
});Then the client:
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
const signal = await createSignalProtocolClient({
identity: { userId },
adapters: { storage: expoStore({ relay }), relay },
});
await signal.syncToServer();
signal.registerHook('onMessageDecrypted', (envelope) => {
appendToConversation(envelope.conversationId, envelope.content);
});
signal.startRelaySubscription();expoStore() is synchronous and takes an optional relay and logger. Configure bindings first because the store reads them lazily on its first query.
The seven questions
These come up in every integration. Direct answers, from the SDK's actual behaviour, with the boundary marked wherever the SDK leaves the decision to you.
1. Where are the keys stored?
The database key is a random 32-byte AES-256 key held under the identifier signal_db_encryption_key by ExpoSecureStoreSignalProtocolSecretVault, which is a thin wrapper over expo-secure-store. That resolves to the iOS Keychain and the Android Keystore-backed secure storage.
The code sets the accessibility class deliberately:
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLYAFTER_FIRST_UNLOCK lets background message delivery and recovery work during a screen lock. THIS_DEVICE_ONLY excludes the value from encrypted device backups and transfer to a new device. This exclusion is a security property, and it also explains the answer to question 5.
The SQLCipher database contains all other protocol data. This data includes identity keys, prekeys, sessions, ratchet state, contact trust decisions, and message records.
2. How long are they retained?
Different lifetimes for different material, and none of it is on a session timer:
| Material | Retention |
|---|---|
| Identity key pair | Indefinite. It is the identity; rotating it is an identity change your contacts will see. |
| Signed prekey / Kyber prekey | Rotated on keyRefreshIntervalMs, default 172800000 — 2 days. Old keys are kept briefly so in-flight messages still decrypt. |
| One-time prekeys | Until consumed by an incoming session, or until maxPreKeyAgeMs (14 days) makes them replaceable. |
| Session and ratchet state | Indefinite, until the session is archived or deleted. |
| Skipped message keys | maxMessageKeysStored 1000 per chain, and keyExpirationMs 7 days. |
| Unacknowledged sessions | MAX_UNACKNOWLEDGED_SESSION_AGE_MS, 30 days. |
Ignore the stale weekly comment
Two JSDoc comments in the SDK still say to rotate signed prekeys "weekly". They are stale, and the TypeDoc generator carries that stale text into its output. The keyRefreshIntervalMs default of 172800000, two days, is authoritative.
3. How do I keep keys away from my own backend?
The SDK does not contain a code path that sends a private key. syncToServer() uploads only public material. This material includes the identity public key, signed prekey and signature, Kyber prekeys, and the one-time prekey batch. The SDK writes the private keys to the local store and reads them from that store.
The realistic leak is not the SDK, it is your app. Named concretely, because this is the question people actually ask about Supabase, Firebase, and every analytics SDK in a React Native project:
- Do not put protocol state in a synced table. Do not add a key column to a synced
profilesordevicesrow. The relay handles public keys and envelopes. TheISignalProtocolRelayServerinterface does not accept private material. - Do not log envelopes or store handles. Crash reporters and session-replay tools serialise objects aggressively. Add protocol objects to the redaction list.
- Avoid syncing filesystems. A provider could receive both the encrypted database and its escaped keychain value.
- Do not send plaintext to your backend "for search". Server-side message search can break end-to-end encryption. Build the index on the device.
4. How many prekeys, and when do they refill?
You do not choose the batch size. The SDK generates 100 one-time prekeys at registration and on replenishment. The numbers that matter to you:
| Setting | Default | Meaning |
|---|---|---|
| One-time prekey batch | 100 | Generated per replenishment round. |
preKeyLowThreshold | 50 | When checkPreKeyStatus() reports needsReplenishment and onPreKeyLow fires. |
| Internal replenishment threshold | 10 | The hard floor the client replenishes at on its own. |
preKeyCheckThrottleMs | 12 hours | How often a check will actually run. |
Wire it to something that runs when the app foregrounds:
const status = await signal.checkPreKeyStatus();
// { oneTimePreKeysRemaining, needsReplenishment }5. What happens when the user reinstalls the app?
They lose everything, and this is not recoverable by the SDK.
Uninstalling removes the app's SQLCipher database. The vault entry is THIS_DEVICE_ONLY and excluded from backups. On iOS, keychain items can sometimes survive an app uninstall. Do not depend on this behavior because a surviving key cannot decrypt a deleted database.
So after a reinstall the user is, cryptographically, a new device:
- New identity key pair, new registration ID, new prekeys.
- Every existing session is dead. Message history encrypted locally is gone.
- Every existing contact sees an identity change. The next message produces
IdentityKeyChangedErroror a safety-number change in the UI. - The new identity cannot decrypt messages that peers sent to the old identity while it was still current.
This is the correct behaviour for a protocol with forward secrecy and no key escrow, and it is also the moment your support burden appears. Decide the product response deliberately: recovery, backup, and migration sets out three named profiles with different tradeoffs and no default. Whatever you pick, handle the contact-side experience in identity change and safety numbers rather than letting a raw error reach the UI.
6. What does the relay store?
Public material and opaque envelopes. Nothing that decrypts anything.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
identityKeyPair (private) | yes | never | never | no |
| Identity public key | yes | yes | no | yes — it is the pinned identity |
| Signed prekey + signature | yes | yes (public) | no | yes |
| Kyber prekeys | yes (private) | yes (public) | no | yes |
| One-time prekeys | yes (private) | yes (public) | no | count and consumption are observable |
| Session / ratchet state | yes | never | never | no |
| Message plaintext | yes | never | never | no |
| Message ciphertext | yes | yes | no | size and timing |
senderUserId, targetUserId, device IDs | yes | yes | no | yes — the social graph |
timestamp, messageType, clientMessageId | yes | yes | no | yes |
| Attachment bytes | encrypted first | no | yes, opaque | size, count, timing |
| Attachment media key | yes | in the encrypted message | never | no |
The database key and the SQLCipher database never appear in the "sent to relay" column under any configuration. There is no mode that changes that.
7. What should I do about backups?
The SDK does not include a backup mechanism, and the keychain entry is deliberately excluded from device backups. Without a backup, an uninstall or lost phone means lost history and a new identity. This product choice is defensible and resembles the Signal Messenger approach, but you must make it deliberately.
The three positions, all legitimate, all with something given up:
- No backup. Strongest confidentiality, worst user experience on device loss. Users will lose history and blame you.
- User-held key backup. The user holds a recovery secret that you cannot reproduce. History restores if they kept it. Signal Messenger uses a 64-character recovery key that "Signal cannot recover, reset, or bypass" and therefore protects the key at the cost of more complex support.
- Provider-assisted escrow. This option gives the best recovery experience. You then hold data that can decrypt user content. That responsibility changes your threat model, compliance surface, and product claims.
Device-to-device transfer is different from backup. It moves cryptographic state directly between two user-controlled devices, with no third-party storage. Linked-device provisioning transfers identity but does not transfer sessions or history. See multi-device and device lifecycle.
Full treatment, with three named profiles you can adopt as-is: recovery, backup, and migration.
Production caveats
The SDK is 0.1.x. Public APIs and persisted formats may change before 1.0. It is reviewed continuously by adversarial AI agents; it is not audited by any independent firm. Verify behaviour on both iOS and Android release builds, not just in development: secure-storage and background-delivery behaviour differ.
Next
- Build an encrypted Expo conversation from scratch: the complete build, end to end.
- Local encrypted storage: the store and vault boundary in full.
- Choosing adapters: if Expo is not the only runtime you ship.
- Production checklist: before you ship.