Lupyd.
Advanced Security Web Client

Storing End-to-End Encryption Keys in the Browser: Non-Extractable Keys, WASM and What XSS Can Still Do

Browsers can store end-to-end encryption keys in IndexedDB, OPFS or localStorage, but none of them keeps keys away from script on the same origin. What non-extractable WebCrypto keys protect, why WASM crypto libraries need raw key bytes, and where Lupyd's web client keeps its keys.

Published: Nov 4, 2026
•
11 min read
Storing End-to-End Encryption Keys in the Browser: Non-Extractable Keys, WASM and What XSS Can Still Do

A browser can keep end-to-end encryption keys, but it cannot keep them away from code running on the same origin. Non-extractable WebCrypto keys stop script from reading the key bytes, not from using the key. A WebAssembly crypto library such as libsignal or mls-rs needs raw key bytes in its own memory, so even that protection mostly does not apply. In practice, a browser client is as safe as the code the server sends it and the scripts that end up running in the page. This post covers the storage options, what each one protects against, and exactly where Lupyd's web client keeps its keys today.

We built the Lupyd web client by compiling our Rust messaging engine, Firefly, to WebAssembly (the build is described in How We Built Lupyd's Web Client). Storing keys was the question we spent the most time on, and good write-ups on it are hard to find. Everything about Lupyd below comes from the current code of the web client and of Firefly.

Where a Browser Can Keep Keys

Browsers give a page four places to keep data across reloads. All of them are scoped to the origin, and none of them hides data from other scripts running on that origin.

Browser storage options for key material
Storage What it holds Readable by script on the origin Fit for keys
localStorage UTF-16 strings, synchronous API, about 5 MiB per origin Yes, fully Poor. OWASP advises against storing sensitive data or session identifiers here.
IndexedDB Structured-clone values, including CryptoKey objects Yes, but a non-extractable CryptoKey comes back as a handle, not bytes The only place a non-extractable key can persist
Origin Private File System (OPFS) Files with byte-level access; fast synchronous handles inside workers Yes Good for a database such as SQLite compiled to WASM; no extra protection for the bytes
Cookies Small values sent to the server with requests Not if HttpOnly Wrong place for end-to-end keys: the point of an HttpOnly cookie is that the server reads it

Sources: MDN (localStorage, storage quotas, OPFS), W3C Web Cryptography API, OWASP HTML5 Security Cheat Sheet.

None of these is encrypted by the browser with a key the page controls. MDN describes OPFS as private to the origin and "not visible to the user", which is about access, not encryption. On disk, the data sits in the browser profile, so protection against someone copying files depends on the operating system's disk encryption and account login.

What Non-Extractable WebCrypto Keys Do

The Web Cryptography API lets you create a CryptoKey with extractable set to false. The spec says that flag decides "whether or not the raw keying material may be exported by the application": exportKey() and wrapKey() refuse it. The spec also makes CryptoKey serializable, so IndexedDB can store the key object across reloads without the bytes ever passing through JavaScript.

Storing a non-extractable key
// A non-extractable key: it can be stored and used, never exported.
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  false,                    // extractable: exportKey() and wrapKey() will refuse it
  ["encrypt", "decrypt"],
);

// CryptoKey is serializable, so IndexedDB can store the object itself.
// Your code gets back a handle, not the key bytes.
store.put(key, "device-key");

// But any script running on this origin can load it and call:
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);

That is real protection against one thing: copying the key off the device through script. A script injected into the page cannot send the key bytes to its own server. It can still load the key from IndexedDB and decrypt, sign or unwrap with it for as long as it runs on the origin. The spec's own security section treats script injection as close to remote code execution and warns that it "may allow for the exfiltration of keys or data". A non-extractable key turns "steal the key" into "use the key while you are there". That matters, but it does not keep a compromised page out of your messages.

Why WASM Crypto Libraries Need Raw Key Bytes

Messaging protocols do not fit WebCrypto's model. The Signal Protocol and MLS derive new keys all the time: the Double Ratchet makes a fresh message key for every message, and MLS rebuilds its key schedule on every epoch. Libraries such as libsignal and mls-rs implement that in Rust over byte arrays, and their storage interfaces save and load serialized records: identity key pairs, sessions, prekeys, group states. Compiled to WebAssembly, those bytes live in the module's linear memory, which is a plain ArrayBuffer. There is no point where a CryptoKey handle could stand in for them without rewriting the protocol code around WebCrypto calls.

The usual workaround is a wrapping key: a non-extractable AES-GCM key stays in IndexedDB, and the library's serialized secrets are encrypted with it before they are written to disk.

The wrapping-key pattern
// Illustrative wrapping-key pattern (not what Lupyd's web client does today).
// 1. A non-extractable AES-GCM key lives in IndexedDB.
// 2. The WASM library's raw secrets are encrypted with it before they hit disk.
async function sealSecrets(wrappingKey: CryptoKey, rawSecrets: Uint8Array) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, wrappingKey, rawSecrets);
  return { iv, sealed };    // written to OPFS or IndexedDB
}

// 3. On startup the secrets are decrypted and copied into WASM linear memory,
//    because the Rust library works on bytes, not on CryptoKey handles.
async function openSecrets(wrappingKey: CryptoKey, iv: Uint8Array, sealed: ArrayBuffer) {
  return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, wrappingKey, sealed));
}

Be clear about what this buys you:

  • It helps when only the database file leaks: a bug that uploads or exports it, a backup tool that copies one directory, a debug dump.
  • It does not help against script on the origin. The wrapping key is usable by any script there, and the decrypted secrets sit in WASM memory while the client runs.
  • It helps little against someone who copies the whole browser profile. The wrapping key is in the same profile. The WebCrypto spec does not say how browsers store CryptoKey material on disk, so you cannot count on it being sealed to the device.

Real protection at rest needs a secret that is not on the disk: a passphrase run through a key derivation function, or a hardware-backed value such as the WebAuthn prf extension, which MDN notes can "generate a symmetric key for encrypting sensitive data". Both cost the user something (a passphrase prompt, or a passkey that supports PRF), which is why most web messengers don't do it.

Threat Model: XSS, Extensions, Disk Theft and the Server

Different attackers get different things. This is the table we used when deciding what the web client should and should not claim.

What each attacker can do to keys in a browser client
Attacker Can read stored key bytes Can use keys while present Main mitigations
XSS (injected script on the origin) Yes for raw bytes in storage; no for non-extractable keys Yes No HTML injection sinks, strict CSP, Trusted Types, no third-party scripts
Malicious or compromised browser extension with access to the site Often yes Yes Little the site can do; user hygiene, or a native app
Someone with a copy of the disk or profile Yes, unless the OS disk is encrypted and locked Can replay from the copy Full-disk encryption, user-secret-based wrapping (passphrase or WebAuthn PRF)
The server, or whoever controls what it serves Yes, by shipping different code Yes Code transparency (for example Meta's Code Verify), signed native apps

Sources: W3C Web Cryptography API section 6.2, Chrome Extensions scripting API, MDN, Meta Engineering.

XSS. Content Security Policy limits where scripts can come from; MDN describes it as a guard against cross-site scripting, and the WebCrypto spec names "judicious use of Content Security Policy" as a mitigation for injection. A WASM app needs 'wasm-unsafe-eval' in script-src to compile its module, which MDN calls "a much safer alternative to 'unsafe-eval'". Trusted Types, enforced with the require-trusted-types-for CSP directive, make the browser throw when a plain string reaches an HTML or script injection sink. MDN lists it as Baseline 2026, available across current browsers since February 2026.

Extensions. An extension that has host permission for your site can run code in the page's own JavaScript environment; Chrome's scripting API calls this the MAIN world, "the execution environment shared with the host page's JavaScript". Your CSP does not stop that, and neither does non-extractability. Keeping the crypto in a dedicated worker means the key bytes are not in the page's memory, but code in the page can still ask the worker to do things on the user's behalf.

Server-delivered code. This is the risk people underrate. A native app is installed once and updated through a signed release. A web app is fetched from the server on every load, so whoever controls the server, or forces it, could serve one user a build that sends keys elsewhere, and that user would see nothing. Subresource Integrity does not fix it: SRI pins third-party files to hashes, but those hashes live in the HTML, which comes from the same server. Meta's answer is Code Verify, launched for WhatsApp Web in March 2022 and later extended to Messenger and Instagram Web: a browser extension compares the JavaScript it is served against hashes published through Cloudflare as an independent third party. It only protects users who install it. This is the main reason a signed native app is the better choice for a high-risk threat model, and part of why Lupyd's desktop app uses a native shell (see Tauri vs Electron).

Where Lupyd's Web Client Keeps Its Keys

The web client runs Firefly, compiled to WASM, inside a dedicated Web Worker. Firefly opens two SQLite databases per account in OPFS, /<username>/firefly.db and /<username>/user_messages.db, through the sahpool VFS. The key material lives in the first one, in the same tables the native app uses: identity_keypair, pre_keys, signed_pre_keys, kyber_pre_keys, sessions, and the MLS group states and key packages. For a refresher on what each of those keys is for, see How Encryption and Keys Work.

The identity key is generated on the device and stored in SQLite
// firefly: crates/client/src/db/stores.rs (trimmed)
// Signal identity key pair, generated on first run and kept in the client's SQLite database.
// In the web build that database is a file in OPFS.
if row.is_none() {
    let mut rng = utils::rng();
    let keypair = IdentityKeyPair::generate(&mut rng);
    let registration_id = rng.next_u32() % 32000;
    let device_id = 1 + (rng.next_u32() % 126) as u8;

    crate::sql::query("INSERT INTO identity_keypair (id, keypair, registration_id, device_id, username) VALUES (?, ?, ?, ?, ?)")
        .bind(0)
        .bind(keypair.serialize())
        .bind(registration_id)
        .bind(device_id)
        .bind("")
        .execute(&mut **txn)
        .await?;
}

Here is the honest part. The OPFS databases are not encrypted with an extra key of their own. We do not use a wrapping key or a passphrase in the browser today. The keys are generated in the browser and never sent to our server, but on disk they are only as protected as the browser profile and the operating system's disk encryption. Firefly turns on SQLite's secure_delete, so deleted content is overwritten with zeros rather than left in the file, but that is not encryption.

What the Lupyd web client stores, and where
Data Where Encrypted by the app at rest
Signal identity key, prekeys, Kyber prekeys, sessions; MLS group states and key packages firefly.db in OPFS (SQLite) No
Decrypted message history and search index user_messages.db in OPFS (SQLite) No
Sign-in session (access and refresh tokens) localStorage No
Direct messages queued while offline, before they are encrypted and sent localStorage No
Notification preferences, mutes, theme localStorage No

Source: lupyd-desktop src/web (auth.ts, local-state.ts, session.ts) and crates/web; firefly crates/client.

The headers app.lupyd.com sends are short. Every response gets nosniff, Referrer-Policy: no-referrer and Cache-Control: no-cache, so a deploy reaches every user on their next load. Built assets have content hashes in their names and are cached as immutable.

Response headers of the web client
# lupyd-desktop: public/_headers (app.lupyd.com)
/*
  X-Robots-Tag: noindex, nofollow
  X-Content-Type-Options: nosniff
  Referrer-Policy: no-referrer
  Cache-Control: no-cache

/assets/*
  ! Cache-Control
  Cache-Control: public, max-age=31536000, immutable

There is no Content Security Policy and no Trusted Types enforcement yet. The page loads no third-party script tags: our JavaScript and the WASM module are bundled at build time and served from our own origin, and sign-in talks to Auth0 with plain requests and redirects (PKCE for Google sign-in) rather than loading an Auth0 script. That keeps the list of script sources short, but a strict CSP with 'wasm-unsafe-eval' and Trusted Types is the next layer, and it is on our list. We also do not offer code transparency for the web build, so the server-delivered code risk above applies to us like any other web app.

Eviction, Persistence and Private Windows

Keys in browser storage can disappear without anyone attacking you. MDN explains that storage is "best-effort" by default: under storage pressure, browsers delete the least recently used origin's data, and "all of its data, not parts of it, is deleted at the same time". Safari, with tracking prevention on, deletes script-created data for origins the user has not interacted with in seven days of browser use. Persistent storage, requested with navigator.storage.persist(), is only cleared by the user; Firefox asks the user, while Chrome and Safari decide on their own based on how the user engages with the site.

For a messenger, losing storage means losing the device's identity keys and its message history. Lupyd's server does not keep messages after delivery, so only a chat backup brings them back. The web client asks for persistence after sign-in and logs a warning if it is refused:

Asking for persistent storage
// lupyd-desktop: src/web/session.ts (trimmed)
// The encrypted message database lives in OPFS, which the browser may clear under
// storage pressure unless the site's storage is persistent, and the server does not
// keep delivered messages. Best effort: browsers grant it by their own rules.
function requestPersistentStorage() {
  if (!navigator.storage?.persist) return
  void navigator.storage
    .persisted()
    .then((persisted) => persisted || navigator.storage.persist())
    .then((persisted) => {
      if (!persisted) console.warn("Persistent storage was not granted; the browser may evict local messages")
    })
}

Private windows are the other trap. MDN notes that private browsing data is usually deleted when the session ends. In Firefox private windows, and with the "Never remember history" setting, navigator.storage.getDirectory() exists but throws. Without a check, the app would load, register the browser as a new device, and then have nowhere to keep its keys. The boot code tests OPFS before anything else and stops with an explanation:

Refusing to start where OPFS is blocked
// lupyd-desktop: src/web/boot.ts (trimmed)
// Firefox private windows and the "Never remember history" setting keep getDirectory
// but make it throw, so the app would load, sign in, and then have nowhere to keep
// the encrypted database.
async function storageBlocked(): Promise<string | null> {
  try {
    await navigator.storage.getDirectory()
    return null
  } catch (err) {
    console.error("OPFS unavailable", err)
    return "Lupyd isn't supported in private or incognito windows: ..."
  }
}

In browsers where OPFS does work in a private window, the client runs, and the keys and history go away with the window. Each such session also counts as a new device on the account. Before registering a browser, the client asks the user to confirm, and when the account is at its device limit, the user picks an existing device to sign out first.

Multiple Tabs and Web Locks

Two tabs running the same Signal or MLS state against one database will corrupt it: both advance the same ratchet, both try to apply the same commit. The sahpool VFS also supports only one connection. The Web Locks API solves the coordination problem: a named lock is shared by all tabs and workers of an origin, and only one holder gets it.

One tab owns the encrypted storage
// lupyd-desktop: src/web/session.ts (trimmed)
const TAB_LOCK = "lupyd-web-client"

function requestTabLock(steal: boolean): Promise<boolean> {
  return new Promise((resolve) => {
    navigator.locks
      .request(TAB_LOCK, steal ? { steal: true } : { ifAvailable: true }, async (lock) => {
        if (!lock) { resolve(false); return }
        holdsTabLock = true
        resolve(true)
        await new Promise<never>(() => {})   // hold the lock for the life of the page
      })
      .catch(() => {
        if (holdsTabLock) onTakenOver()      // another tab stole it: stop touching storage
      })
  })
}

The first tab takes the lock with ifAvailable and holds it for the life of the page. A second tab gets null, asks the user whether to take over, and then requests the lock with steal. The first tab's request rejects, so it terminates its worker at once and shows that Lupyd moved to another tab.

What We Would Recommend

  • Keep crypto in a dedicated worker. It keeps key bytes out of the page's memory and is required anyway for fast OPFS access.
  • Ship a strict CSP and Trusted Types. Use script-src 'self' 'wasm-unsafe-eval', no inline script without a hash, and require-trusted-types-for 'script'.
  • Load no third-party scripts on the origin that holds the keys. Analytics and chat widgets run with the same power as your own code.
  • Use non-extractable keys for anything WebCrypto can do itself, and a wrapping key for WASM secrets if your threat model includes leaked files. If it includes stolen disks, derive the wrapping key from something the user has: a passphrase or a WebAuthn PRF credential.
  • Request persistent storage, detect blocked storage, and lock across tabs before you create any keys.
  • Tell users the trade-off. A web client trusts the server's code on every load. For high-risk use, a signed native app is the better choice.

Frequently Asked Questions

Is it safe to store encryption keys in localStorage?

Not if you can avoid it. Any script on the origin can read localStorage, and OWASP warns that a single cross-site scripting bug can steal everything in it. IndexedDB with a non-extractable CryptoKey is better, though injected script can still use that key.

Do non-extractable WebCrypto keys protect against XSS?

Partly. Injected script cannot export the key bytes and take them away, but it can load the key and decrypt or sign with it for as long as it runs on the page. They stop key theft, not key use.

Why can't a WebAssembly Signal or MLS library use non-extractable keys?

Libraries such as libsignal and mls-rs derive new keys constantly and do the cryptography in their own code over byte arrays, so they need the raw secrets in WASM memory. A non-extractable AES-GCM wrapping key can encrypt those secrets on disk, but they are decrypted into memory while the client runs.

Are Lupyd web client keys encrypted at rest?

Not with an extra key of their own. The Signal and MLS keys are generated in the browser, never sent to the server, and stored in a SQLite database in the browser's Origin Private File System. On disk they are protected by the browser profile and the operating system's disk encryption.

Does Lupyd work in private or incognito windows?

The web client checks the Origin Private File System before starting and refuses to run where the browser blocks it, as Firefox private windows do. Where private windows allow it, keys and history are deleted when the window closes, and the browser counts as a new device each time.

Final Takeaway

A browser can hold end-to-end encryption keys well enough for everyday use, but it cannot protect them from code that runs on the same origin, whether that code came from an injection bug, an extension or the server itself. Non-extractable keys narrow what an attacker can take, wrapping keys narrow what a leaked file reveals, and a strict CSP, Trusted Types and no third-party scripts narrow who gets to run code at all. Lupyd's web client keeps its keys on the device in OPFS, uses Web Locks and persistent storage, and refuses to start where storage is blocked. It does not yet encrypt that storage with a separate key or send a CSP, and we would rather say so than imply otherwise.

If you want the trade-offs of a native app instead, Download Lupyd for desktop or mobile, or try the web client at app.lupyd.com.

Last updated: 4 November 2026.

Key Takeaways
  • ✓ localStorage, IndexedDB, OPFS and cookies are all scoped to the origin, and none of them hides stored data from other script running on that origin.
  • ✓ Non-extractable WebCrypto keys can be stored in IndexedDB and cannot be exported, but injected script can still use them to decrypt or sign while it runs.
  • ✓ WebAssembly Signal and MLS libraries need raw key bytes in linear memory; a non-extractable AES-GCM wrapping key protects leaked files, not a compromised page.
  • ✓ A web app's code comes from the server on every load, so a strict CSP, Trusted Types, no third-party scripts and code transparency matter as much as storage choice.
  • ✓ Lupyd's web client keeps its Signal and MLS keys in SQLite in OPFS without a separate at-rest key, requests persistent storage, lets only one tab use that storage through a Web Lock, and refuses to start where OPFS is blocked; it does not send a CSP yet.
Try Lupyd

The workspace where nothing leaks

End-to-end encrypted channels and chat for your team, built on the Signal Protocol and MLS. Available on macOS, Windows, Linux and Android.

Open Source

Explore Lupyd on GitHub

Inspect our open-source repositories, cryptography libraries, and client tools on GitHub.

Explore Lupyd on GitHub