Encrypting a Browser Database at Rest

This page answers one task: a local-first application stores user data in SQLite compiled to WebAssembly, persisted in the Origin Private File System, and the data is sensitive — notes, health records, financial data. You want the database file on disk encrypted, so someone with access to the device’s browser profile cannot read it, while queries keep working normally once the user unlocks the app.

Prerequisites

  • [ ] SQLite running in Wasm with OPFS persistence (the official sqlite3 Wasm build or a wrapper such as wa-sqlite).
  • [ ] A key source: a password-derived key, a key from your server, or a WebAuthn-derived secret.
  • [ ] A worker in which the database runs (OPFS synchronous access handles require one).

What at-rest encryption protects

OPFS files live inside the browser’s profile directory on disk. Browsers do not encrypt them beyond whatever full-disk encryption the operating system provides. Anyone who copies the profile — another user of a shared computer, malware reading files, a forensic tool, a backup — can open an unencrypted SQLite file directly. Encrypting the database at rest means every page written to disk is ciphertext; the plaintext exists only in the Wasm module’s memory while the app is unlocked.

It does not protect against code running in the page while it is unlocked (an XSS payload can query the database like the app does), against a compromised browser, or against someone who obtains the key. It is a defence for data at rest, not a substitute for securing the running application.

Where encryption sits in the SQLite-Wasm stack The application issues SQL queries to the SQLite engine in Wasm, which reads and writes pages through a pager. An encryption layer encrypts each page with a key held in memory before the VFS writes it to OPFS, and decrypts pages read back, so only ciphertext reaches disk. application queries SQL as usual SQLite engine (Wasm) B-trees, pager page encryption layer AES or ChaCha20 per page VFS (OPFS access handles) reads/writes pages OPFS file on disk ciphertext only

Step 1 — choose an encrypting SQLite build

Encryption is best done at SQLite’s page level, below the SQL engine and above the file system. Two approaches are common. SQLite3 Multiple Ciphers is an open-source extension that implements several cipher schemes (including SQLCipher-compatible ones) and has been built for WebAssembly; it adds PRAGMA key and related pragmas to SQLite. SQLCipher itself is widely used natively; its format is supported by SQLite3 Multiple Ciphers, which matters if the same database file must also open in a native app. Alternatively, a custom VFS can encrypt pages on their way to OPFS, giving you full control at the cost of maintaining crypto code — and of getting details such as nonces and integrity right.

Step 2 — open the database with a key

With an encryption-enabled build, the key is supplied immediately after opening, before any other statement:

// in the database worker
const db = new sqlite3.oo1.OpfsDb("/notes.db");
db.exec(`PRAGMA key = "x'${hexKey}'";`);       // raw 256-bit key as hex, not a password
db.exec("SELECT count(*) FROM sqlite_master;"); // fails here if the key is wrong

Passing a raw key (in the x'…' hex form) rather than a passphrase skips the library’s built-in KDF, so you control derivation with your own Argon2id parameters. The first real query verifies the key: with a wrong key, SQLite reports that the file is not a database.

Step 3 — derive and wrap the key properly

Do not use the password-derived key directly as the database key. Generate a random database key once, use it for the database, and store it wrapped (encrypted) by a key-encryption key derived from the user’s password with Argon2id. Unlocking derives the key-encryption key, unwraps the database key and opens the database. Changing the password re-wraps one small blob instead of re-encrypting the whole database; adding recovery (a second wrapped copy under a recovery key) is equally cheap. Key derivation is covered in deriving keys from passwords in Wasm.

Unlocking an encrypted browser database The user enters a password. Argon2id derives a key-encryption key. The wrapped database key stored beside the database is unwrapped with it. The database worker opens the OPFS file and supplies the raw database key with PRAGMA key. Queries then run normally, with pages decrypted in memory. password user unlock Argon2id → KEK in a worker unwrap DB key random 256-bit PRAGMA key open OPFS database queries as usual pages decrypted in memory

Step 4 — lock and unlock per session

Keep the database key only in the worker’s memory while unlocked. On lock — explicit, after inactivity, or when the tab is hidden for a while — close the database and drop the key; terminating the database worker is the most thorough way, since it discards the whole Wasm memory including SQLite’s page cache of plaintext. Unlock re-derives and reopens. Long derivation times make frequent locking annoying, so choose an inactivity timeout that matches the data’s sensitivity.

Step 5 — measure the cost

Page encryption adds CPU work to every page read from or written to disk; pages served from SQLite’s cache are already decrypted. Measure typical queries and bulk imports with and without encryption. Expect a modest slowdown for read-heavy workloads with a warm cache and a larger one for write-heavy work and cold starts. Increasing SQLite’s cache size (PRAGMA cache_size) reduces decryption work for repeated reads, at the cost of more plaintext in memory.

Migrating an existing unencrypted database

Encrypting an existing database means rewriting it. Open the plaintext database, attach a new encrypted database with a key, and copy everything with sqlcipher_export-style functions (SQLite3 Multiple Ciphers supports equivalent mechanisms) or with VACUUM INTO where supported for the target build, then replace the old file in OPFS and delete it. Do it in the worker, with progress reporting and a backup copy until the new database verifies. Deleting the old file removes it from OPFS, though not necessarily from the physical disk blocks; full-disk encryption remains the defence for that.

Backups and sync

Encrypted databases can be backed up as-is — the file is ciphertext — which makes server-side backups of local-first data safe to store without the server being able to read them. Sync protocols that operate on rows or changes, however, see plaintext inside the worker; encrypt those payloads separately if the server should not read them. See backing up and exporting browser databases.

Integrity, not just confidentiality

Encryption hides content; it does not by itself stop someone from modifying the file. Cipher schemes for SQLite differ here: some include a message authentication code per page, so a tampered page fails to decrypt, while others use unauthenticated modes, where flipping bits in the ciphertext produces garbage plaintext that SQLite may or may not detect. For data where tampering matters — financial records, signed documents — choose a scheme with per-page authentication (SQLCipher’s default format uses HMAC per page) and verify it is enabled in your build. Authentication cannot detect a rollback to an older, validly encrypted copy of the file; if that threat matters, keep a version counter outside the file, for example on the server, and compare it on unlock.

Testing the encrypted setup

Write tests that check the security properties rather than just the happy path: the OPFS file contains none of a set of known plaintext strings after inserting them; opening with a wrong key fails before any data is returned; locking clears the key (a subsequent query from the app fails until unlock); changing the password re-wraps the database key and the old password no longer works; and a backup copy of the file restores with the right key. Run these in a real browser with OPFS, since in-memory test databases would miss the storage layer entirely.

Expected output

The OPFS file notes.db contains no readable strings (strings notes.db shows nothing meaningful); unlocking derives the key-encryption key in 700 ms on a phone and opens the database; a wrong password fails at the first query; locking terminates the database worker; and a read-heavy benchmark is 8% slower than unencrypted with a warm cache.

Gotchas

  • Using the password as the database key. Password changes need full re-encryption. Wrap a random key.
  • Passing a passphrase to PRAGMA key. The library’s KDF parameters may not suit you. Pass a raw derived key.
  • Keeping plaintext in a long-lived worker. SQLite’s cache holds decrypted pages. Terminate on lock.
  • Assuming encryption protects the running app. XSS can still query. Harden the page too.
  • Mixing plaintext sync payloads with encrypted storage. Encrypt sync data separately if needed.

Performance note

For a 200 MB notes database on a laptop, a full-text search over cached pages was 4% slower encrypted; a cold-start search that read 30 MB from disk was 18% slower; bulk import of 50,000 notes was 22% slower.

Slowdown from page encryption by workload Percentage slowdown of an encrypted SQLite-Wasm database compared with unencrypted for a cached full-text search, a cold-start search reading 30 MB from OPFS, and a bulk import of 50,000 rows. slowdown vs unencrypted (%) cached search 4 % cold-start search 18 % bulk import 22 %

Frequently Asked Questions

Can I use WebCrypto inside the VFS? WebCrypto is asynchronous, which does not fit SQLite’s synchronous VFS; encryption in Wasm avoids that mismatch.

Is IndexedDB encrypted by browsers? No more than other profile data; encrypt sensitive values yourself.

Can the same file open in a native app? Yes, if both use a compatible cipher scheme such as SQLCipher’s format.

Does encryption protect against data loss? No — it adds risk if the key is lost. Provide recovery wrapping.

Does page encryption detect tampering? Only with an authenticated scheme such as SQLCipher’s per-page HMAC; check that your build enables it.

← Back to Databases & Persistent Storage in Wasm