Signal Protocol in React Native and Expo in 2026
The official JavaScript library is archived. Its replacement is a Node native add-on, and the community port has an open trust-check bug.
Who this is for: a React Native or Expo developer who plans to add end-to-end encryption. Search results show several packages with different platform and maintenance limits. This article compares those packages and builds two encrypted identities in an Expo app. It also explains key storage, key lifetime, relay data, and prekey capacity.
Four dated search results
1. GitHub marks the official JavaScript library as archived.
signalapp/libsignal-protocol-javascript (opens in a new tab) shows its latest push on 4 August 2021. Its repository description reads “This library is no longer maintained.” The repository had 1,960 stars on 1 August 2026.
It ranked first in the 24 July 2026 GitHub search for “signal protocol typescript.” A tutorial updated in January 2026 (opens in a new tab) still called it “the official JavaScript implementation of the Signal Protocol.”
2. The replacement does not target React Native.
Signal actively maintains @signalapp/libsignal-client (opens in a new tab). The npm package is a Node native add-on. It provides prebuilt binaries for Windows, macOS, and Debian-flavored Linux, and no iOS, Android, or browser build. Hermes is the default React Native JavaScript engine, and it does not implement Node’s N-API. Configuration cannot add this missing runtime interface. The README defines the supported scope:
This repository is used by the Signal client apps (Android, iOS, and Desktop) as well as server-side. Use outside of Signal is unsupported. In particular… All APIs and implementations are subject to change without notice.
3. The community TypeScript port last shipped in May 2023.
@privacyresearch/libsignal-protocol-typescript (opens in a new tab) is pure TypeScript and can run in React Native.
Its latest npm publish was 6 May 2023. Its latest repository push was 18 July 2023. The package has no group or post-quantum support. Its GPL-3.0-only license requires review for a closed-source mobile app.
Issue #92 (opens in a new tab) remained open on 1 August 2026. The issue title states: “Possible inbound PreKey trust-check bypass — isTrustedIdentity() Promise is not awaited in SessionBuilder.processV3().” The issue reports an unawaited promise in an inbound trust check. The July 2026 research snapshot measured roughly 29,800 downloads in 30 days.
4. A public WebAssembly attempt fails on Hermes.
The matrix-js-sdk tracker records Error: Unable to bind Webassembly to React Native JSI., js engine: hermes. A developer then asks, “has anyone here successfully initialized Rust crypto (or even the legacy crypto) in React Native?” (opens in a new tab). react-native-webassembly shows its latest repository push on 3 November 2023. Its latest npm publish occurred on 10 May 2023.
A January 2021 Stack Overflow question asks “How to build a highly secure End to End Encryption React Native messaging app” (opens in a new tab). On 24 July 2026, it had 8,666 views and zero answers.
The runtime problem underneath it
React Native also lacks several required cryptographic primitives.
Hermes has no Web Crypto. Cryptographic libraries commonly call crypto.getRandomValues(), which Hermes does not provide. The usual polyfill is react-native-get-random-values. It must load before code that requests randomness.
The uuid package changed its code (opens in a new tab) to remove this implicit load-order dependency. Expo broke the polyfill while converting expo-random to JSI. Expo now deprecates expo-random.
expo-crypto provides digests, random bytes, randomUUID, and AES-GCM. It exposes no SubtleCrypto, ECDH, HKDF, X25519, or Ed25519 (opens in a new tab). This surface provides no key agreement. X3DH and the Double Ratchet require key agreement, so this API cannot implement the complete protocol.
react-native-quick-crypto does not run in Expo Go (opens in a new tab). An open Android issue (opens in a new tab) reports a libcrypto.so collision with SQLCipher-based libraries. An encrypted app can need both components.
Reported Hermes measurements also show a cost for pure JavaScript crypto. Wallet creation took 33.5s on Hermes and 14.4s on JSC (opens in a new tab) on an iPhone 11 Pro. crypto-js operations increased from 1.5s to 6s (opens in a new tab) in another report.
The OpenE2EE Signal Protocol SDK implements the protocol in TypeScript. It uses @noble/curves, @noble/hashes, @noble/ciphers, and @noble/post-quantum. The protocol code adds no native crypto module. It checks globalThis.crypto.getRandomValues, then Node’s webcrypto, then expo-crypto’s getRandomBytesAsync. Expo therefore needs no randomness polyfill import. If no secure source exists, the SDK throws a specific error and does not use an insecure fallback.
The encrypted local store uses expo-sqlite with SQLCipher. Expo’s SQLCipher instructions (opens in a new tab) require useSQLCipher configuration and a native build. SQLCipher is not available in Expo Go. A project can run npx expo prebuild directly. Expo build tools (opens in a new tab) also run Prebuild automatically when the native directories do not exist. SQLCipher still requires native configuration and a development build.
Two encrypted identities in an Expo app
Install:
npm install @open-e2ee/signal-protocol-sdk
npx expo install expo-sqlite expo-secure-store expo-crypto
Tell the Expo store how to reach your database. You own the connection. The SDK owns the schema:
import { configureSignalProtocolExpoDbBindings } from '@open-e2ee/signal-protocol-sdk/local/store/expo/db';
configureSignalProtocolExpoDbBindings({
getDrizzle: async () => drizzleDatabase,
getRawDatabase: () => rawDatabase,
});
Use the same client composition on both devices:
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { expoStore } from '@open-e2ee/signal-protocol-sdk/local/store/expo';
import { convexRelay } from '@open-e2ee/signal-protocol-sdk/remote/relay/convex';
const relay = convexRelay({ convex, api: api.signal, currentUserId: userId });
const signal = await createSignalProtocolClient({
identity: { userId },
adapters: { storage: expoStore(), relay },
});
signal.registerHook('onMessageDecrypted', (message) => {
addToConversation(message.content);
});
signal.startRelaySubscription();
await signal.send(peerUserId, 'Dinner at 7.');
On first run, createSignalProtocolClient() generates the identity keys. With a relay, it publishes the public material during initial synchronization. The first send establishes a session.
The defaults are postQuantum: 'required' and braid: 'required'. PQXDH uses ML-KEM-1024 for session establishment. The braid setting requires the post-quantum ratchet. There is no public postQuantum: 'disabled' mode.
If you have no relay yet, use inMemoryRelay() from @open-e2ee/signal-protocol-sdk/remote/relay/memory. The same code then runs entirely in memory, against real protocol and real cryptography.
Where the keys live
An r/reactnative thread (opens in a new tab) asked where an Expo app should store keys. It also asked about key lifetime and preventing leaks to other apps or the relay. The thread had no answer during the 24 July 2026 review.
Private key material stays inside the device. Only the public prekey bundle leaves it. A solid notch marks private material, and an open notch marks the bundle that travels.
Concretely, three places:
Expo SecureStore holds one small secret: the database key. Construct new ExpoSecureStoreSignalProtocolSecretVault() from @open-e2ee/signal-protocol-sdk/local/vault/expo-secure-store. Its interface contains getSecret, setSecret, and deleteSecret. It takes no options. The SDK fixes the accessibility class to AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY.
On Apple platforms, THIS_DEVICE_ONLY excludes the secret from iCloud Keychain backups. Another device therefore cannot restore that secret. AFTER_FIRST_UNLOCK permits background access after the user authenticates following a restart. A freshly restarted and locked phone cannot access it.
The SQLCipher-encrypted SQLite database holds everything else. It contains identity keys, contact trust records, EC and Kyber prekeys, and session records. It also contains sender keys, message records, and group state. The SecureStore key encrypts this database at rest. The database file alone is insufficient.
The relay never needs message plaintext or device private keys. It is not blind. It sees public prekey material, opaque envelopes, and routing metadata. The relay adapter receives the public prekey bundle and ciphertext. Its interface has no private-key field.
Do not upload or synchronize the SQLite file with the relay. Before a generic “device backup” feature includes this file, read Recovery, backup, and migration (opens in a new tab).
How long they live, and how many
Key lifetime determines whether the app still works in month six.
The SDK generates 100 one-time prekeys at a time. ONE_TIME_PREKEY_BATCH_SIZE is 100 for both EC and ML-KEM prekeys. One incoming session establishment consumes one prekey. The relay must consume it atomically to prevent two senders from receiving the same prekey.
Prekey exhaustion weakens forward secrecy without an error. Without a one-time prekey, session establishment falls back to the signed prekey. This fallback weakens forward secrecy for that session. A 2025 WhatsApp measurement found 13% of companion devices lacked a one-time prekey at scan time (opens in a new tab). The SDK exposes a low-watermark callback and status check:
const signal = await createSignalProtocolClient({
identity: { userId },
adapters: { storage: expoStore(), relay },
preKeyLowThreshold: 50,
onPreKeyLow: (remaining) => {
console.warn(`${remaining} one-time prekeys left`);
},
});
const status = await signal.checkPreKeyStatus();
// { oneTimePreKeysRemaining: number, needsReplenishment: boolean }
The SDK throttles the status check. A throttled call returns oneTimePreKeysRemaining: -1 with needsReplenishment: false. It does not block or count again. Treat a negative count as “no reading this time,” not as zero.
preKeyLowThreshold defaults to 50, which is half a batch. keyRefreshIntervalMs is 2 days. maxPreKeyAgeMs is 14 days. preKeyCheckThrottleMs is 12 hours. A foregrounded app therefore checks at most twice a day, not on every message. The 14-day maximum age and 2-day refresh interval leave a twelve-day recovery window for an offline device.
For a headless task or push handler, call rotateKeysHeadless(relay, userId, deviceId, { storage }). Import it from @open-e2ee/signal-protocol-sdk/client/headless. It rotates and replenishes keys without a full client.
The second device, and first contact
A linked device does not hold the session keys that encrypted past messages. Provisioning transfers existing account identity material, but not sessions or message history. Device 1 is the primary. The relay allocates ids from 2 through 5 to linked devices.
The primary device uses generateProvisioningQR. The new device uses parseProvisioningQR and receiveProvisioningMessage. Import these functions from @open-e2ee/signal-protocol-sdk/device/provisioning.
State transfer uses prepareOldDeviceTransfer and restoreDeviceBackup from @open-e2ee/signal-protocol-sdk/device. Transfer clones the identity private key and live ratchet state. If both devices remain active, a peer message that one device decrypts desynchronizes the other. Cloned state also weakens the forward-secrecy bound because one endpoint compromise exposes state that the other device shares. The deviations document (opens in a new tab) records both limits. Your product policy must choose between fresh provisioning and state transfer.
The SDK records trust on first use (TOFU) as UNVERIFIED_TOFU. It pins one composite X25519 and Ed25519 identity tuple per (userId, identityType). If either component changes, the SDK fails closed until the app calls acceptIdentityRotation. Pinning detects a later change. It does not prove who was present at first contact.
signal.verify(userId) returns a safety number for contact verification. A study that explained the risks (opens in a new tab) found that only 13% completed key verification. Design the identity-change banner for users who do not complete this ceremony.
What to do next
The Expo guide covers SQLCipher setup, vault bootstrap, a real relay, offline sends, out-of-order delivery, and identity changes.
Follow the complete Expo guide → (opens in a new tab)
The Expo quickstart (opens in a new tab) gives the ten-minute version. The Convex relay guide (opens in a new tab) covers the relay. The migration guide (opens in a new tab) applies if you shipped the privacyresearch port. Review the production checklist (opens in a new tab) before release.