The LocalMode Encryption Stack: PBKDF2 Key Derivation, AES-256-GCM, and Encrypted Embeddings
A deep technical walkthrough of LocalMode's zero-knowledge encryption pipeline. From PBKDF2 key derivation with configurable iterations to AES-256-GCM authenticated encryption of metadata, text, and exported vectors -- all running entirely in the browser via the Web Crypto API. No server ever sees a plaintext byte.
Most "privacy-first" AI libraries mean one thing: they do not phone home. Your data stays on the device during inference. That is necessary, but it is not sufficient. If your embedding vectors sit in IndexedDB as raw Float32Arrays, anyone with access to the device -- a shared laptop, a stolen phone, a compromised browser extension -- can read them. Worse, research has shown that embedding vectors can be inverted to reconstruct the original text with surprising accuracy.
LocalMode ships an encryption stack that addresses this: document metadata is encrypted at rest, exported vectors can be encrypted, and the vectors the live index must keep readable can be stripped of PII and noised before they are stored. Every layer -- key derivation, authenticated encryption, persistent key management, VectorDB encryption middleware -- runs in the browser through the Web Crypto API. No external cryptography libraries. No server round-trips. Zero knowledge by construction.
This post walks through every layer of that stack, from the raw cryptographic primitives to the high-level middleware that encrypts your VectorDB transparently.
Working demo
The Encrypted Vault block implements the complete encryption pipeline described here. Create a vault, add encrypted entries, lock and unlock with a master password -- all running locally in your browser with AES-256-GCM encryption.
Layer 1: Key Derivation with PBKDF2
The entire encryption pipeline starts with a password. Users do not think in terms of 256-bit keys; they think in terms of passwords. The bridge between a human-memorizable string and a cryptographically strong key is PBKDF2 (Password-Based Key Derivation Function 2).
LocalMode's deriveEncryptionKey() function wraps the Web Crypto API's SubtleCrypto.deriveKey() to produce an AES-256-GCM CryptoKey from a password, a salt, and an iteration count:
import { deriveEncryptionKey } from '@localmode/core';
// Derive an encryption key from a user password
const { key, salt } = await deriveEncryptionKey('user-password');
// Store the salt for future key derivation (it is not secret)
localStorage.setItem('encryption-salt', salt);
// Later, re-derive the same key with the stored salt
const { key: sameKey } = await deriveEncryptionKey('user-password', storedSalt);Under the hood, this executes two Web Crypto operations in sequence:
- Import the password as raw key material --
crypto.subtle.importKey('raw', encodedPassword, { name: 'PBKDF2' }, false, ['deriveKey'])converts the UTF-8 encoded password into a non-extractableCryptoKeythat can only be used for derivation. - Derive the AES-256 key --
crypto.subtle.deriveKey()runs PBKDF2 with HMAC-SHA-256 as the pseudorandom function, the provided salt, and the configured iteration count, producing a 256-bit AES-GCM key.
The returned CryptoKey is non-extractable (extractable: false). The raw key bytes never enter JavaScript memory. They exist only inside the browser's cryptographic subsystem, inaccessible to application code, browser extensions, or in-memory inspection tools.
Salt Generation
When no salt is provided, deriveEncryptionKey() generates 16 random bytes (128 bits) via crypto.getRandomValues(). The salt is returned as a base64 string for easy storage. It is not secret -- its purpose is to ensure that the same password produces different keys for different databases, defeating precomputed rainbow table attacks.
Iteration Count: The Security-Performance Tradeoff
LocalMode defaults to 100,000 PBKDF2 iterations. Each iteration runs one HMAC-SHA-256 computation, so the total derivation cost scales linearly.
The OWASP Password Storage Cheat Sheet currently recommends a minimum of 600,000 iterations for PBKDF2-SHA-256 in server-side password hashing. Some security researchers advocate for 310,000+ iterations as a practical floor in 2025-2026.
LocalMode's default of 100,000 is a deliberate tradeoff for client-side key derivation in the browser. Key derivation happens on the user's device -- which might be a mid-range phone, not a server with dedicated hardware. At 100,000 iterations, derivation takes approximately 50-150ms on modern hardware. At 600,000, it would take 300-900ms, creating a noticeable delay on every vault unlock.
The iteration count is fully configurable:
// High-security: match OWASP server-side recommendations
const { key, salt } = await deriveEncryptionKey('password', undefined, 600_000);
// Default: 100,000 iterations (good browser-side tradeoff)
const { key: defaultKey } = await deriveEncryptionKey('password');For applications handling highly sensitive data (medical records, legal documents, financial information), raising iterations to 300,000-600,000 is straightforward. The Keystore class (Layer 4) persists the iteration count alongside the key metadata, so the correct value is always used for re-derivation.
Layer 2: AES-256-GCM Authenticated Encryption
With a derived CryptoKey in hand, the next layer handles the actual encryption. LocalMode uses AES-256-GCM (Galois/Counter Mode) -- a NIST-standardized authenticated encryption with associated data (AEAD) cipher.
Why AES-256-GCM?
GCM provides three properties in a single pass:
| Property | What It Means |
|---|---|
| Confidentiality | Ciphertext reveals nothing about the plaintext without the key |
| Integrity | Any modification to the ciphertext is detected on decryption |
| Authenticity | The 128-bit authentication tag proves the ciphertext was produced by someone who holds the key |
This matters because IndexedDB is not a tamper-proof store. A malicious extension or XSS attack could modify stored ciphertext to inject crafted data. With GCM's authentication tag, any such modification causes decryption to fail with an explicit error rather than silently returning corrupted plaintext.
The Encrypt Function
The encrypt() function accepts strings or raw ArrayBuffer data:
// Password-based helpers: each call derives its own key from the password.
// Inside a VectorDB, encryptionMiddleware() with deriveEncryptionKey() does this for you.
import { encrypt, decryptString } from '@localmode/core';
// Encrypt a string
const encrypted = await encrypt('Sensitive medical record', 'user-password');
// Returns: { ciphertext, iv, salt, algorithm: 'AES-GCM', version: 1 }
// Decrypt it back
const plaintext = await decryptString(encrypted, 'user-password');
// 'Sensitive medical record'Each call to encrypt() generates:
- A fresh 16-byte salt for key derivation (via
crypto.getRandomValues) - A fresh 12-byte IV (initialization vector / nonce) for AES-GCM
- A derived AES-256 key from the password + salt via PBKDF2
- The ciphertext + authentication tag via
crypto.subtle.encrypt()
The EncryptedData structure stores everything needed for decryption:
interface EncryptedData {
ciphertext: string; // Base64-encoded ciphertext + auth tag
iv: string; // Base64-encoded 12-byte IV
salt: string; // Base64-encoded 16-byte PBKDF2 salt
algorithm: 'AES-GCM'; // Algorithm identifier
version: 1; // Schema version for future compatibility
}Nonce Uniqueness: The Critical Invariant
AES-GCM has one absolute requirement: the (key, IV) pair must never repeat. Reusing an IV with the same key completely breaks both confidentiality and authenticity. Since LocalMode generates a fresh random salt for every encrypt() call, a different key is derived each time -- making IV collisions irrelevant even if the same 12-byte IV were drawn twice. In the encryption middleware (Layer 5), where a single derived key is reused across multiple operations, a fresh 12-byte IV is generated per operation via crypto.getRandomValues(), keeping the collision probability at approximately 2^-48 per operation -- safely below the practical threshold for any client-side workload.
Specialized Encryption Functions
Beyond string encryption, the crypto module provides type-specific wrappers:
import { encryptVector, decryptVector, encryptJSON, decryptJSON } from '@localmode/core';
// Metadata encryption inside a VectorDB is handled automatically by encryptionMiddleware.
// encryptVector/decryptVector are standalone helpers for vectors kept outside the live
// index (for example an exported snapshot); the index itself needs plaintext vectors.
// Encrypt a Float32Array (embedding vector)
const vector = new Float32Array([0.1, 0.2, 0.3, /* ... 384 dimensions */]);
const encryptedVector = await encryptVector(vector, 'password');
const decryptedVector = await decryptVector(encryptedVector, 'password');
// decryptedVector is identical to the original Float32Array
// Encrypt arbitrary JSON
const metadata = { patientId: 'P-12345', diagnosis: 'confidential' };
const encryptedMeta = await encryptJSON(metadata, 'password');
const decryptedMeta = await decryptJSON(encryptedMeta, 'password');The encryptVector() function passes the Float32Array's underlying ArrayBuffer directly to AES-GCM, avoiding any intermediate serialization. For a 384-dimensional embedding (1,536 bytes), the encrypted output is 1,536 bytes of ciphertext + 16 bytes of auth tag + 12 bytes of IV + 16 bytes of salt -- roughly 1,580 bytes total, an overhead of under 3%.
Layer 3: Passphrase Verification
Encryption protects data at rest, but users need a way to know whether they entered the correct password before attempting to decrypt an entire database. The crypto module includes constant-time passphrase verification:
import { hashPassphrase, verifyPassphrase } from '@localmode/core';
// On vault creation: hash and store
const hash = await hashPassphrase('user-password');
// SHA-256 hash, base64-encoded
// On unlock: verify before attempting decryption
const isValid = await verifyPassphrase('user-password', storedHash);The verifyPassphrase() function uses a constant-time comparison loop to prevent timing attacks -- each character is compared via XOR accumulation regardless of whether an earlier character mismatched:
// Constant-time comparison (from crypto.ts)
let result = 0;
for (let i = 0; i < newHash.length; i++) {
result |= newHash.charCodeAt(i) ^ hash.charCodeAt(i);
}
return result === 0;This is not a full timing-attack-proof implementation (JavaScript's runtime characteristics make true constant-time guarantees difficult), but it eliminates the most obvious timing leaks from early-exit string comparison.
Layer 4: The Keystore -- Persistent Key Management
Raw encryption functions are low-level primitives. The Keystore class provides the session management layer that applications actually interact with:
// For encrypting a VectorDB, the encryptionMiddleware + deriveEncryptionKey pattern is recommended.
import { createKeystore } from '@localmode/core';
const keystore = createKeystore();
// First time: initialize encryption for a database
await keystore.initialize('my-vector-db', 'master-password', 100_000);
// Later sessions: unlock with the password
const isValid = await keystore.unlock('my-vector-db', 'master-password');
// Check status
keystore.isUnlocked(); // true
// When done: clear the password from memory
keystore.lock();The Keystore persists key metadata in a dedicated IndexedDB database (vectordb_keystore), separate from application data. Each entry stores:
| Field | Purpose |
|---|---|
dbName | Which database this key belongs to |
passphraseHash | SHA-256 hash for password verification |
iterations | PBKDF2 iteration count used during initialization |
createdAt | Timestamp for audit trails |
lastUsedAt | Timestamp updated on every unlock |
enabled | Whether encryption is currently active |
The Keystore never stores the password or the derived key in IndexedDB. The password lives only in JavaScript memory while the vault is unlocked, and is cleared to null on lock(). The derived CryptoKey is non-extractable by the Web Crypto API's design.
Key Lifecycle Operations
The Keystore supports the full lifecycle of an encryption key:
// Change password (verifies old password first)
await keystore.changePassphrase('my-db', 'old-password', 'new-password');
// Disable encryption (requires password verification)
await keystore.disable('my-db', 'master-password');
// Delete all key metadata
await keystore.delete('my-db');
// Check if a database has encryption configured
const hasEncryption = await keystore.hasEncryption('my-db');Layer 5: The Encryption Middleware -- Transparent VectorDB Encryption
The layers above are building blocks. The encryptionMiddleware ties everything together into a single VectorDB middleware that encrypts document metadata on write and decrypts it on read -- completely transparently to application code.
import {
createVectorDB,
wrapVectorDB,
encryptionMiddleware,
deriveEncryptionKey,
embed,
} from '@localmode/core';
import { transformers } from '@localmode/transformers';
// 1. Derive encryption key from user password
const { key } = await deriveEncryptionKey('master-password', storedSalt);
// 2. Create a standard VectorDB
const db = await createVectorDB({ name: 'encrypted-notes', dimensions: 384 });
// 3. Wrap it with encryption middleware
const encryptedDb = wrapVectorDB({
db,
middleware: encryptionMiddleware({ key }),
});
// 4. Use normally -- encryption is transparent
const model = transformers.embedding('Xenova/bge-small-en-v1.5');
const { embedding } = await embed({ model, value: 'Patient diagnosed with...' });
await encryptedDb.add({
id: 'note-1',
vector: embedding,
metadata: { title: 'Medical Note', content: 'Patient diagnosed with...' },
});
// 5. Search works normally -- result metadata is decrypted automatically
const results = await encryptedDb.search(queryVector, { k: 5 });
// results[0].metadata.content === 'Patient diagnosed with...' (decrypted)What Gets Encrypted
The middleware encrypts document metadata; the vector stays in plaintext:
| Data | Flag | Default | Method |
|---|---|---|---|
| Vectors | encryptVectors | false (must stay false) | Not encrypted: the index computes distances over them. Passing true throws a ValidationError |
| Metadata strings | encryptText | true | AES-GCM per field; result stored as { __encrypted: true, ciphertext, iv }. With encryptText: false, strings stay plaintext (and filterable) |
| Metadata objects, numbers, booleans | encryptMetadata | true | JSON-serialized then encrypted as string (also when encryptText is false) |
You can exclude specific metadata fields from encryption -- useful for fields that need to remain filterable:
const encryptedDb = wrapVectorDB({
db,
middleware: encryptionMiddleware({
key,
excludeFields: ['category', 'timestamp'],
// 'category' stays as plaintext for filtered search
// 'timestamp' stays readable for sorting
}),
});Why Vectors Stay in Plaintext
An HNSW index stores each vector and computes distances over it on every search. AES-GCM ciphertext is a different length from the original Float32Array and bears no mathematical relationship to it, so a ciphertext vector could neither be stored in a collection with fixed dimensions nor searched. The middleware therefore leaves vectors untouched and encrypts everything else about the document; encryptVectors: true is rejected with a ValidationError rather than silently producing a database that cannot be written to.
Plaintext vectors and embedding inversion
Because the indexed vectors are plaintext, they are readable to anyone with access to the device's IndexedDB, and embeddings can be partially inverted. Reduce what they can leak: redact PII before embedding (Layer 6 below) and consider differential-privacy noise on the embeddings (dpEmbeddingMiddleware). To protect vectors at rest outside the live index, encrypt an exported snapshot with encryptVector().
The Practical Pattern: Encrypted Metadata, Sanitized Vectors
The most common architecture for private semantic search in LocalMode is:
- On unlock: derive the key and wrap the VectorDB with
encryptionMiddleware - During session: add and search as usual; metadata is encrypted on write, and
get()and search results come back decrypted - On lock: drop the wrapped database and the derived key
This keeps the readable content of every document -- its text, names, and other fields -- unreadable on disk without the password, while HNSW search runs at full speed over the plaintext vectors. The Encrypted Vault block demonstrates the passphrase lock/unlock lifecycle.
Layer 6: Combining Encryption with Other Middleware
The encryption middleware composes with LocalMode's other middleware through wrapVectorDB:
import {
wrapVectorDB,
encryptionMiddleware,
loggingMiddleware,
validationMiddleware,
} from '@localmode/core';
const secureDb = wrapVectorDB({
db,
middleware: [
validationMiddleware({ dimensions: 384 }), // Validate first
loggingMiddleware({ level: 'info' }), // Log operations
encryptionMiddleware({ key }), // Encrypt last (before storage)
],
});You can also combine it with PII redaction on the embedding model side for defense in depth:
import { wrapEmbeddingModel, piiRedactionMiddleware } from '@localmode/core';
// Redact PII before embedding; encryptionMiddleware encrypts metadata before storage
const safeModel = wrapEmbeddingModel({
model: transformers.embedding('Xenova/bge-small-en-v1.5'),
middleware: piiRedactionMiddleware({ emails: true, phones: true, ssn: true }),
});
// PII is stripped before it reaches the model
// Metadata is encrypted before it reaches storageThis two-layer approach means the stored embeddings -- which the index keeps in plaintext -- do not encode any PII, because it was redacted before the embedding model ever saw it.
Browser Compatibility
The entire encryption stack depends on the Web Crypto API, specifically crypto.subtle. Browser support is effectively universal across modern browsers:
| Browser | crypto.subtle | PBKDF2 | AES-GCM |
|---|---|---|---|
| Chrome 37+ | Yes | Yes | Yes |
| Firefox 34+ | Yes | Yes | Yes |
| Safari 11+ | Yes | Yes | Yes |
| Edge 12+ | Yes | Yes | Yes |
One important constraint: crypto.subtle is only available in secure contexts (HTTPS or localhost). LocalMode's isCryptoSupported() function checks for availability before any encryption operation:
// Check for Web Crypto API availability (HTTPS or localhost required)
const isCryptoAvailable = typeof globalThis.crypto?.subtle !== 'undefined';
if (!isCryptoSupported()) {
// Fallback: warn the user or disable encryption features
console.warn('Web Crypto API not available -- encryption disabled');
}Security Properties Summary
| Property | Guarantee |
|---|---|
| Key derivation | PBKDF2 with HMAC-SHA-256, configurable iterations (default 100K), 128-bit random salt |
| Encryption | AES-256-GCM with 12-byte random IV per operation |
| Authentication | 128-bit GCM auth tag detects any ciphertext tampering |
| Key exposure | CryptoKey is non-extractable; raw key bytes never enter JS memory |
| Password exposure | Cleared from memory on lock(); never persisted to storage |
| Nonce reuse | Fresh random IV per operation; fresh salt per encrypt() call |
| Timing attacks | Constant-time passphrase comparison in verifyPassphrase() |
| Zero knowledge | No server component; all cryptography runs in the browser |
What This Does Not Protect Against
Transparency requires acknowledging limitations:
- Weak passwords: PBKDF2 slows brute-force attacks but cannot compensate for a four-character password. Enforce minimum length and complexity at the application layer.
- Memory inspection: While the
CryptoKeyis non-extractable, decrypted plaintext exists in JavaScript memory during processing. A compromised browser process could read it. - Side-channel attacks: JavaScript's runtime does not guarantee constant-time execution. The comparison loop in
verifyPassphrase()mitigates but does not eliminate timing leaks. - Key management: If the user loses their password, the data is unrecoverable by design. This is a feature, not a bug -- but users must understand it.
Getting Started
Install the core package and a provider:
npm install @localmode/core @localmode/transformersSet up encrypted storage in under 20 lines:
import { createVectorDB, wrapVectorDB, encryptionMiddleware, deriveEncryptionKey, embed } from '@localmode/core';
import { transformers } from '@localmode/transformers';
// Derive key from password
const { key, salt } = await deriveEncryptionKey('master-password');
// Create a VectorDB with encrypted metadata
const db = await createVectorDB({ name: 'private-notes', dimensions: 384 });
const encryptedDb = wrapVectorDB({
db,
middleware: encryptionMiddleware({ key }),
});
// Embed and store -- metadata encryption is automatic
const model = transformers.embedding('Xenova/bge-small-en-v1.5');
const { embedding } = await embed({ model, value: 'Confidential document text' });
await encryptedDb.add({ id: '1', vector: embedding, metadata: { text: 'Confidential document text' } });
// Search result metadata is decrypted transparently
const results = await encryptedDb.search(embedding, { k: 5 });For a complete working application, explore the Encrypted Vault block, which demonstrates vault creation, password-based unlock, entry encryption/decryption, and locking -- all built on the stack described in this post.
Further reading
- Security documentation -- full API reference for encryption, PII redaction, and security middleware
- Storage documentation -- VectorDB configuration and middleware composition
- Middleware documentation -- how to compose encryption with logging, caching, and validation middleware
Frequently Asked Questions
- How does LocalMode encrypt embedding vectors in the browser?
- encryptVector() passes the Float32Array buffer directly to AES-256-GCM via the Web Crypto API, with a fresh salt and 12-byte IV per call. It is meant for vectors kept outside the live index, such as an exported snapshot. The VectorDB encryptionMiddleware encrypts metadata but leaves stored vectors in plaintext, because the index must read them to store and search them; passing encryptVectors: true throws a ValidationError.
- What key derivation does LocalMode use for encryption?
- LocalMode uses PBKDF2 with HMAC-SHA-256 to derive a 256-bit AES-GCM CryptoKey from a user password. The default is 100,000 iterations with a 128-bit random salt. The CryptoKey is non-extractable -- raw key bytes never enter JavaScript memory.
- Can you search encrypted vectors with similarity search?
- No. Encrypted vectors are ciphertext with no mathematical relationship to the originals, so an HNSW index can neither store nor search them. That is why encryptionMiddleware keeps the indexed vectors in plaintext and encrypts the metadata (the text, names, and other fields) instead. To protect vectors at rest outside the live index, encrypt an exported snapshot with encryptVector().
- What is the encryption overhead for embedding vectors?
- For a 384-dimensional embedding (1,536 bytes), the encrypted output adds roughly 44 bytes of overhead -- 16 bytes for the authentication tag, 12 bytes for the IV, and 16 bytes for the salt -- under 3% total overhead.
- Does LocalMode's encryption work in all browsers?
- Yes, the Web Crypto API (crypto.subtle) with PBKDF2 and AES-GCM support is available in Chrome 37+, Firefox 34+, Safari 11+, and Edge 12+. The only requirement is a secure context (HTTPS or localhost).