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.

LocalMode··Updated

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:

  1. 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-extractable CryptoKey that can only be used for derivation.
  2. 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:

PropertyWhat It Means
ConfidentialityCiphertext reveals nothing about the plaintext without the key
IntegrityAny modification to the ciphertext is detected on decryption
AuthenticityThe 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:

  1. A fresh 16-byte salt for key derivation (via crypto.getRandomValues)
  2. A fresh 12-byte IV (initialization vector / nonce) for AES-GCM
  3. A derived AES-256 key from the password + salt via PBKDF2
  4. 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:

FieldPurpose
dbNameWhich database this key belongs to
passphraseHashSHA-256 hash for password verification
iterationsPBKDF2 iteration count used during initialization
createdAtTimestamp for audit trails
lastUsedAtTimestamp updated on every unlock
enabledWhether 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:

DataFlagDefaultMethod
VectorsencryptVectorsfalse (must stay false)Not encrypted: the index computes distances over them. Passing true throws a ValidationError
Metadata stringsencryptTexttrueAES-GCM per field; result stored as { __encrypted: true, ciphertext, iv }. With encryptText: false, strings stay plaintext (and filterable)
Metadata objects, numbers, booleansencryptMetadatatrueJSON-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:

  1. On unlock: derive the key and wrap the VectorDB with encryptionMiddleware
  2. During session: add and search as usual; metadata is encrypted on write, and get() and search results come back decrypted
  3. 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 storage

This 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:

Browsercrypto.subtlePBKDF2AES-GCM
Chrome 37+YesYesYes
Firefox 34+YesYesYes
Safari 11+YesYesYes
Edge 12+YesYesYes

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

PropertyGuarantee
Key derivationPBKDF2 with HMAC-SHA-256, configurable iterations (default 100K), 128-bit random salt
EncryptionAES-256-GCM with 12-byte random IV per operation
Authentication128-bit GCM auth tag detects any ciphertext tampering
Key exposureCryptoKey is non-extractable; raw key bytes never enter JS memory
Password exposureCleared from memory on lock(); never persisted to storage
Nonce reuseFresh random IV per operation; fresh salt per encrypt() call
Timing attacksConstant-time passphrase comparison in verifyPassphrase()
Zero knowledgeNo 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 CryptoKey is 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/transformers

Set 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

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).