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
sqlite3Wasm 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.
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.
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.
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.
Related
- Persisting a Wasm database to OPFS — the storage layer.
- Running SQLite in the browser with Wasm — the base setup.
- Deriving keys from passwords in Wasm — key derivation.
- Encrypting files client-side with Wasm — file-level alternative.
← Back to Databases & Persistent Storage in Wasm