OpenE2EE

Browser

Set up the IndexedDB adapter, with direct answers on key storage, retention, prekeys, clearing site data, and backups — and an honest account of the browser threat model.

Status
pre-1.0
Applies to
0.1.0
Platforms
Browsers with IndexedDB and Web Crypto
Prereqs
Quickstart
Reading time
12 min

The browser is the fastest way to see the protocol work and the hardest place to make strong claims about it. Both facts are on this page, and the second one is not a footnote.

Supported adapter, unchanged threat model

IndexedDbSignalProtocolStore implements the full ISignalProtocolLocalStore contract and is a supported adapter: every gate on its graduation checklist — contract suites in real Chromium, Firefox, and WebKit, multi-tab, interruption, storage-pressure, and soak — runs on every change to the source repository. What graduation does not change is the browser threat model below. A deployment still requires the origin-security review the SDK's adapter guide describes.

Install

npm install @open-e2ee/signal-protocol-sdk@0.1.0

No peer dependencies. The adapter uses IndexedDB and Web Crypto, both built in.

Setup

import { createSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { indexedDbStore } from '@open-e2ee/signal-protocol-sdk/local/store/web';

// Async: it opens the database and runs initialization before returning.
const storage = await indexedDbStore();

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

await signal.syncToServer();

signal.registerHook('onMessageDecrypted', (envelope) => {
  render(envelope.conversationId, envelope.content);
});

signal.startRelaySubscription();

indexedDbStore() returns a promise, unlike expoStore(). That difference is deliberate and easy to trip on when porting code between the two.

Before the seven questions: the browser problem

Two things are true about browser E2EE that do not apply to the Expo build, and neither is fixable by choosing a better storage adapter.

The server in your threat model ships the code that encrypts. Each page load downloads your JavaScript from your origin. A reviewer checks a native app. Its publisher signs it, and a user installs it on a device.

The server can deliver a web application differently for each user or load. Subresource integrity and code-transparency proposals exist. However, no generally accepted path establishes trust in web application code. This is a structural platform property.

Non-extractable keys are not a safety guarantee. Web Crypto can mark a key as non-extractable and store it in IndexedDB. An attacker then cannot extract the raw bytes. However, a script on your origin can read the handle from IndexedDB. It can call sign() or decrypt() with attacker-selected input while the page is open. The attacker does not need the raw key bytes. Under XSS, a non-extractable key is a signing and decryption oracle.

The SDK adapter documentation states the same limit for encryption at rest. The adapter creates a random 32-byte database key in the IndexedDB metadata store. It "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."


The seven questions

1. Where are the keys stored?

In IndexedDB, under your origin, via IndexedDbSignalProtocolStore. The adapter persists identity keys, contact trust decisions, prekeys, sessions, sender keys, device records, and retry metadata, with records encrypted using AES-256-GCM through Web Crypto.

The 32-byte database key sits in the same IndexedDB metadata store. The browser has no equivalent of the iOS Keychain. No OS-level facility can hold this key outside page JavaScript because page JavaScript must use it.

This creates a narrow boundary. It protects a record-store copy if the copy does not include the metadata key. It does not protect against code on your origin.

2. How long are they retained?

The protocol lifetimes are identical to every other runtime: signed prekey rotation on keyRefreshIntervalMs (default 172800000, two days), maxPreKeyAgeMs 14 days, maxMessageKeysStored 1000, keyExpirationMs 7 days, MAX_UNACKNOWLEDGED_SESSION_AGE_MS 30 days.

What differs is the storage lifetime, and the browser is the only runtime where it is out of your control:

  • The user can clear site data at any time from browser UI, wiping everything instantly.
  • Private and incognito windows discard the origin's storage when the session ends.
  • Browsers apply storage eviction under disk pressure. They can evict origins without a persistence grant. Call navigator.storage.persist() and handle refusal. A granted persistence hint is not a durability guarantee.

Safari's Intelligent Tracking Prevention has historically capped script-writable storage lifetime for origins without user interaction. Assume browser storage is durable-ish, not durable.

3. How do I keep keys away from my own backend?

Same protocol answer as everywhere: no code path in the SDK transmits a private key. syncToServer() uploads public material only.

The browser-specific leak paths are different and worth listing, because they are easy to create accidentally:

  • Session replay and analytics tools. Products that record the DOM capture decrypted message text during rendering. This commonly defeats browser E2EE. A non-engineering team can install these tools without understanding the boundary.
  • Error reporting. Serialising a caught error with attached context can ship envelope contents to a third party. Redact protocol objects.
  • Server-side rendering. If plaintext is available in a server render path, it was on your server. Keep decryption strictly client-side.
  • Third-party scripts. Every script on the origin has the same access your app does. A tag manager is a key-usage oracle.

If your backend is Supabase, Firebase, or similar: the relay only ever needs public keys and opaque envelopes. If you add a private-key column to a synced table, stop. The ISignalProtocolRelayServer interface exposes no private fields.

4. How many prekeys, and when do they refill?

Unchanged from other runtimes: batch of 100, preKeyLowThreshold 50, internal replenishment floor 10, preKeyCheckThrottleMs 12 hours.

const status = await signal.checkPreKeyStatus();
// { oneTimePreKeysRemaining, needsReplenishment }

The browser wrinkle is when to run it. A closed tab is not a backgrounded app: there is no equivalent of a background fetch that quietly replenishes. Other people consume your users' prekeys while nobody has your site open. Check on load and on visibilitychange, and expect longer exhaustion windows than on mobile.

5. What happens when the user clears site data?

Clearing site data is the browser equivalent of a reinstall. A privacy sweep, closed incognito window, or storage eviction can cause it without user intent.

The consequences match a mobile reinstall. The identity key, sessions, and local history are gone. Each contact sees an identity change during the next contact. The SDK has no recovery path because no server holds a copy.

Do not treat a browser profile as a durable identity. Account survival across machines and browser resets requires a deliberate mechanism. See question 7. If the account does not survive, tell the user that clearing site data starts a new identity.

6. What does the relay store?

Opacity ledger

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
identityKeyPair (private)yes, in IndexedDBneverneverno
Identity public keyyesyesnoyes — the pinned identity
Signed prekey + signatureprivate half localpublic halfnoyes
Kyber prekeysprivate half localpublic halfnoyes
One-time prekeysprivate half localpublic halfnocount and consumption observable
IndexedDB database keyyes, in IndexedDB metadataneverneverno
Session / ratchet stateyesneverneverno
Message plaintextyesneverneverno
Message ciphertextyesyesnosize and timing
Sender / recipient IDs, device IDsyesyesnoyes — the social graph
timestamp, messageType, clientMessageIdyesyesnoyes
Attachment bytesencrypted firstnoyes, opaquesize, count, timing

Worth doing once, in the console: log a SendResult next to the string you passed to send(). The gap between them is the product. Everything in the "sent to relay" column above is what a relay operator, or anyone who compels one, actually has.

7. What should I do about backups?

The SDK ships no backup mechanism. Browsers provide no keychain, device transfer, or OS backup that your application can configure.

Realistic positions:

  • Ephemeral by design. Treat the browser identity as scoped to that browser profile. Simplest and most honest. Users lose history on any reset. Good for demos, support chat, and anything where history is not the product.
  • User-held recovery secret. Export an encrypted bundle the user stores themselves. Restores work. A lost secret is unrecoverable, and you cannot help. Signal Messenger's recovery key provides the reference example for this stance: deliberately something the operator "cannot recover, reset, or bypass."
  • Provider-assisted escrow. Best experience, and you now hold something that can decrypt user data. Matthew Green's warning about how that capability spreads once it exists applies directly: "Once you have a hammer like SVR, you're going to want to use it to knock down other nails."

See recovery, backup, and migration for three named profiles with explicit tradeoffs.


Other browser constraints worth knowing

  • No streaming API. Issue w3c/webcrypto#73 opened in 2016 and still tracks the gap. Each SubtleCrypto call needs one complete payload buffer. Large attachments therefore need chunked framing, and browser memory limits apply. See encrypted attachments.
  • X25519 in Web Crypto has roughly 84% global support (Chrome/Edge 133+, Firefox 130+, Safari 17.0+). This SDK does not depend on Web Crypto X25519. Every platform uses a pure-TypeScript key-agreement implementation. This browser gap explains the portable implementation.
  • Cross-tab coordination is your problem. Two tabs on the same origin share one IndexedDB and can race on ratchet state. Coordinate with a lock or a single leader tab.

Next

On this page