Build a browser E2EE demo with two local identities
Two identities in one page, one real encrypted round trip, and a side-by-side look at what the relay holds versus what the app renders.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Browser
- Prereqs
- Node 20+, any bundler that serves ES modules, and a browser with IndexedDB
- Reading time
- 14 min
This example shows two identities and an encrypted round trip in one page. It places the relay's message copy beside the app's copy. The cryptography is real. The relay is an in-memory stand-in that lives in a JavaScript variable, as each relevant section states.
A supported store inside a demo stack
IndexedDbSignalProtocolStore / indexedDbStore() from /local/store/web is a supported adapter: it implements the full ISignalProtocolLocalStore contract, and its graduation gates — real-browser contract suites, multi-tab, interruption, storage-pressure, and soak — run on every change to the source repository. The rest of this page's stack is not production material: the relay is an in-memory stand-in, and the browser threat model needs its own review. Do not ship this page's stack to production users without reading Production caveats at the bottom, twice.
Intended audience
Use this guide to evaluate the SDK with a short feedback loop or build a browser-only internal tool. Browser E2EE has a structural limitation that no library fixes.
Prerequisites
- Node 20 or newer and any bundler serving ES modules (Vite is fine).
- A browser with IndexedDB. No native modules, no build plugins, no WASM.
- E2EE is not TLS and Threat model. The second one matters more here than on any other page.
0.1.x; public APIs and persisted formats may change before 1.0.
What this guide builds
Install
npm install @open-e2ee/signal-protocol-sdk@0.1.0 idbidb is the peer dependency the browser store uses. You need nothing else: the protocol implementation is pure TypeScript, so there is no WASM asset, worker bundle, or native step.
Stage 1: two identities, one relay
The browser adapter opens a fixed IndexedDB database named signal-protocol-storage. There is no per-instance database name, so two indexedDbStore() instances in one origin share one database and one identity. For the second identity in this demo, use inMemoryStore(): in-memory, development only, and honest about it.
import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { indexedDbStore } from '@open-e2ee/signal-protocol-sdk/local/store/web';
import { inMemoryStore } from '@open-e2ee/signal-protocol-sdk/local/store/memory';
import { inMemoryRelay } from '@open-e2ee/signal-protocol-sdk/remote/relay/memory';
const relay = inMemoryRelay(); // development only: in-memory, no auth, no persistence
await relay.registerDevice('alice', { encryptedDeviceName: new ArrayBuffer(0) });
await relay.registerDevice('bob', { encryptedDeviceName: new ArrayBuffer(0) });
const alice = await createSignalProtocolClient({
identity: { userId: 'alice' },
adapters: { storage: await indexedDbStore(), relay },
});
const bob = await createSignalProtocolClient({
identity: { userId: 'bob' },
adapters: { storage: inMemoryStore(), relay },
});
await alice.syncToServer();
await bob.syncToServer();indexedDbStore() is async because it calls initialize() for you: it opens the database, creates the object stores, and generates or loads a 32-byte database key. On first run it prints a warning to the console, verbatim:
[IndexedDbSignalProtocolStore] Database encryption key stored in IndexedDB. Ensure Content Security Policy (CSP) is configured to mitigate XSS risks.That warning is not decoration. Come back to it after the round trip.
syncToServer() generates identity material and a batch of one-time prekeys and uploads only public halves and signatures. Post-quantum policy is postQuantum: 'required' and braid: 'required' by default. Both identities here satisfy it, so session establishment is PQXDH end to end.
Stage 2: send, then look before you subscribe
This is the moment the demo exists for. Send first. Do not start Bob's subscription yet: while the envelope is still pending, InMemorySignalProtocolRelayServer will show you exactly what a relay holds.
await alice.send('bob', 'the auditor asks what the server can read');
const [envelope] = relay.getPendingMessages('bob', 1);
console.log({
targetUserId: envelope.targetUserId, // 'bob'
targetDeviceId: envelope.targetDeviceId, // 1
senderUserId: envelope.senderUserId, // 'alice'
senderDeviceId: envelope.senderDeviceId, // 1
messageType: envelope.messageType, // 'prekey_bundle' on the first message
timestamp: envelope.timestamp,
serverTimestamp: envelope.serverTimestamp,
id: envelope.id,
ciphertextBytes: envelope.ciphertext.length,
});Render envelope.ciphertext beside the eventual plaintext. It is base64 or bytes. Either form is not searchable, summarizable, or readable without the recipient's session state. Every other field remains readable: participants, target device, time, size, and envelope type. The messageType field shows whether this is a first contact or an established conversation.
Now open the other side:
bob.registerHook('onMessageDecrypted', async (message) => {
renderBubble({ from: message.senderId, body: message.content });
});
bob.startRelaySubscription(); // delivery and local decryption start hereRegister the hook before starting the subscription. startRelaySubscription() logs a warning and returns without subscribing if no onMessageDecrypted hook exists.
The DecryptedEnvelope includes messageId, sessionId, senderId, senderDeviceId, conversationId, content, timestamp, serverTimestamp, receivedAt, and isGroup. Put the two panels side by side. The same message is opaque on one side and visible on the other, while both sides show its metadata.
After delivery, relay.getPendingMessages('bob', 1) returns an empty array. This result shows that the relay's job ends at delivery. Any retention after delivery is an explicit schema decision.
Stage 3: inspect the store itself
Open DevTools → Application → IndexedDB → signal-protocol-storage. The object stores are metadata, identity, contacts, prekeys, sessions, securityEvents, sesameUsers, senderKeyRecords, skippedSenderKeys, and messageRecords. Record values are AES-256-GCM ciphertext with a 12-byte IV.
Then open metadata and find the key databaseKey. Those are the raw 32 bytes that decrypt everything else, sitting in the same database as the records they protect.
Browser-specific honesty
This section is longer than the build. That ratio is deliberate: this is where web E2EE claims usually overreach.
The code-delivery problem is not solved
The server in this threat model also ships the JavaScript that encrypts data. It can serve a different bundle to one user. Client-side cryptography inside that bundle cannot detect the change. The state of the art is honest about this: "these mechanisms for trusting web apps are still pretty early and there's no blessed path." Subresource integrity binds a bundle to a hash from the same server. Code-transparency schemes exist but lack broad deployment. If your threat model includes your origin, use an installed application with a signed update channel.
Non-extractable keys would be a signing oracle anyway: and here they are not even that
A common browser mitigation is a non-extractable CryptoKey handle in IndexedDB. XSS on your origin can read the handle from IndexedDB. The malicious code can then call sign() or decrypt() with chosen input while the page remains open. "The attacker never touches the raw key bytes" and does not need to.
Be precise about what this adapter does, because it is not even that. It generates 32 random bytes with crypto.getRandomValues and persists them in the metadata store. Each operation re-imports them with crypto.subtle.importKey('raw', …, extractable: false, …), so the imported handle is non-extractable, but the raw bytes are already sitting in the database. The SDK's own README states the boundary: this "protects record contents from a copy that does not also contain the metadata key. It does not protect against JavaScript running with the application's origin, because that code can access both the encrypted records and their encryption key… Encryption at rest is therefore not an XSS defense."
Use a restrictive Content Security Policy in response headers. Prohibit inline and evaluated scripts, and minimize third-party scripts. Escape untrusted content through the framework. Review dependency and service-worker updates. Provide an account-reset path that deletes IndexedDB and other same-origin state.
WebCrypto has no streaming API
Issue w3c/webcrypto#73 opened in May 2016 and still tracks this gap. crypto.subtle.encrypt takes and returns a complete buffer. A tab must either hold a 2 GB file in memory or implement chunked AEAD framing. The second option requires custom nonce rules and integrity chaining. See encrypted attachments and object storage.
X25519 in WebCrypto still is not everywhere
X25519 has 84.45% global support: Chrome and Edge 133+, Firefox 130+, and Safari 17.0+. About 15% of browser traffic cannot use it natively. A library that uses WebCrypto key agreement would not run for those users. This SDK instead implements the protocol in TypeScript with @noble/curves and @noble/post-quantum. The demo therefore runs in browsers where WebCrypto cannot exchange keys. Pure TypeScript lets the demo support this browser set.
What the backend can see
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
databaseKey (IndexedDB metadata) | yes | no | n/a | no |
identityKeyPair private half (identity) | yes | no | n/a | no |
identityKeyPair public half / CompositeIdentityV1 | yes | yes | n/a | yes |
EcSignedPreKey public half + signature | yes | yes | n/a | yes |
EcOneTimePreKey public halves | yes | yes | n/a | count |
KemOneTimePreKey public halves + signatures | yes | yes | n/a | count |
All prekey private halves (prekeys) | yes | no | n/a | no |
SessionRecord (version: 4), sessions store | yes | no | n/a | no |
Contact trust records (contacts) | yes | no | n/a | no |
messageRecords, skippedSenderKeys, sesameUsers | yes | no | n/a | no |
Message plaintext / envelope.content | yes | no | n/a | no |
Envelope ciphertext | yes | yes | n/a | size |
targetUserId, targetDeviceId, senderDeviceId | yes | yes | n/a | yes |
messageType, timestamp, clientMessageId | yes | yes | n/a | yes |
encryptedDeviceName | yes | yes | n/a | ciphertext length |
Review questions
Where do the keys live? In IndexedDB, database signal-protocol-storage, via IndexedDbSignalProtocolStore. Private key material sits in the identity and prekeys object stores as AES-256-GCM ciphertext. The key that decrypts it sits in metadata under databaseKey. There is no OS keychain involved and no browser API that would provide one.
How long is anything retained? Data remains until the browser clears the origin's storage or you delete it. Sessions persist until deleteSession, archiveSession, or clearAllData. MAX_UNACKNOWLEDGED_SESSION_AGE_MS is 30 days. Skipped message keys expire after keyExpirationMs, which defaults to 7 days. The limits are maxMessageKeysStored 1000 and maxSkip 1000. Browser eviction under storage pressure remains outside your control.
How do I keep this away from my backend? The SDK does not give private keys to a network adapter. Your application code creates the primary risk. Do not log envelope.content, send decrypted bodies to analytics, or include plaintext in server-rendered data. Remember that your backend ships code that could complete all three actions.
How many prekeys, and when do they refill?
Each batch contains 100 one-time prekeys for both EC and Kyber.
ONE_TIME_PREKEY_BATCH_SIZE defines this batch, and the maximum is 200 EC prekeys.
preKeyLowThreshold defaults to 50, while the internal replenishment threshold is 10.
keyRefreshIntervalMs is 172800000 (2 days). maxPreKeyAgeMs is 1209600000 (14 days). syncToServer() replenishes keys. checkPreKeyStatus() returns { oneTimePreKeysRemaining, needsReplenishment }.
What happens when the user clears site data?
This action is the browser equivalent of a reinstall. Users can trigger it by accident, and some browsers remove data under storage pressure or after inactivity. The browser removes the identity key, sessions, and private prekeys. The next syncToServer() publishes a new identity, so every peer sees an identity change.
Handle IdentityKeyChangedError through .changedAddress, .oldIdentityKey, and .newIdentityKey. Use acceptIdentityRotation(userId, identity, identityType?). The method trustIdentity() does not exist. See identity change and safety numbers.
What does the relay store? In this demo, a JavaScript Map holding public prekey bundles, device records, and pending envelopes. In production, whatever your relay's schema keeps: ciphertext plus routing metadata, public key material, device records. The relay never needs message plaintext or device private keys.
What about backups? There are none, and there is no browser API that would give you a durable, device-bound key store to build one on. A browser-only deployment means device loss is history loss unless you design an explicit encrypted export. Recovery, backup, migration covers what the SDK does offer.
Failure and recovery behaviour
Production caveats
- The demo stack is not a product.
inMemoryRelay()andinMemoryStore()are development only, and a real deployment needs the origin-security review in browser setup. Nothing on this page should reach production users unchanged. - 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. - Not wire-compatible with Signal Messenger and not affiliated with it: public keys and ciphertexts require exactly
0x0A || raw ML-KEM-1024 bytes, and "the0x0Aencoding is distinct from Signal Messenger deployments that use round-3 Kyber1024 tagged0x08." - Pure-JS crypto is not FIPS 140-validated. The "FIPS 203 ML-KEM-1024 behavior" quote describes algorithm behaviour, not validation.
- E2EE is not anonymity and it is not compliance. The relay still sees who talks to whom, when, and how much: see Limits and metadata.
Next
- Build an encrypted Expo conversation from scratch: the primary supported adapter
- Choosing adapters: what each store is and is not validated for
- Threat model: where a browser deployment lands in it
- Error handling: the full dispatch table for
EncryptionErrorCode
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.
Connect an Expo app to a Convex encrypted-envelope relay
Replace the in-memory relay with a Convex deployment you own, and get one-time-prekey consumption right under concurrency.