I Built a Zero-Dependency Browser Storage Encryption Library β Here's Why
Client-Side Storage Hardening: Architecting Zero-Dependency Browser Encryption
Current Situation Analysis
Modern web applications increasingly rely on client-side storage mechanisms like localStorage, sessionStorage, IndexedDB, and cookies to manage state, preferences, and session tokens. The convenience is undeniable, but it introduces a critical security blind spot: browser storage is plaintext by default. Any script executing within the same origin can read, modify, or exfiltrate these values instantly. For applications handling sensitive user data, authentication tokens, or financial cart information, storing this data unencrypted is equivalent to leaving a database dump on a public server.
The industry response has historically been fragmented. Developers either ignore the risk entirely, assuming "client-side data isn't that sensitive," or they reach for heavyweight cryptographic libraries that bundle polyfills, introduce dozens of dependencies, and bloat the JavaScript payload. Others attempt to roll their own encryption, frequently falling into well-documented traps: weak key derivation functions, predictable initialization vectors, or storing cryptographic keys alongside the encrypted data.
This problem is overlooked because the threat model is frequently misunderstood. Many teams assume that encryption only matters for data in transit or at rest on the server. They fail to account for passive observation (DevTools), cross-site scripting (XSS) payloads that scrape storage, or physical device access. The OWASP 2024 guidelines explicitly address this gap, recommending password-based key derivation using PBKDF2 with a minimum of 310,000 iterations to mitigate offline brute-force attacks. Modern browsers have shipped the Web Crypto API for years, providing native, hardware-accelerated cryptographic primitives without external dependencies. Yet, production-grade implementations remain rare because developers lack a clear architectural blueprint for managing key lifecycles, cross-tab synchronization, and memory safety in a zero-dependency environment.
WOW Moment: Key Findings
The shift from plaintext storage to production-grade client-side encryption fundamentally alters the threat landscape. The following comparison illustrates the operational and security differences between common storage strategies:
| Storage Strategy | DevTools Visibility | XSS Impact | Key Derivation Cost | Cross-Tab Sync Overhead |
|---|---|---|---|---|
| Plaintext Storage | Immediate read/write | Full data exposure | None | None |
| Naive Client Encryption | Ciphertext | Ciphertext (weak KDF) | ~40β80ms | Manual polling / storage events |
| Production-Grade Client Encryption | Ciphertext | Ciphertext (strong KDF) | ~900β1100ms | BroadcastChannel native |
Why this matters: Plaintext storage offers zero resistance to origin-level compromise. Naive encryption provides obscurity but fails under automated cracking due to insufficient iteration counts and poor key management. Production-grade client encryption, leveraging native Web Crypto APIs with OWASP-compliant parameters, transforms the attack surface. An attacker intercepting storage values faces ciphertext that requires ~1 second of computation per password guess, effectively neutralizing automated scraping and offline brute-force attempts. The ~1 second derivation cost is a deliberate trade-off: it's fast enough for UX when cached in memory, but slow enough to deter mass cracking. Cross-tab synchronization via BroadcastChannel eliminates stale sessions without polling, reducing overhead to near-zero while maintaining strict lockstep across all active windows.
Core Solution
Building a secure, zero-dependency browser encryption layer requires careful orchestration of cryptographic primitives, memory management, and storage serialization. Below is a production-ready architectural blueprint using TypeScript and the native Web Crypto API.
1. Key Derivation & Salt Management
The foundation of any password-based encryption system is key derivation. We use PBKDF2-SHA-256 with 310,000 iterations to stretch a user passcode into a cryptographically strong AES-256 key. A vault-level salt is generated once and persisted in localStorage to ensure consistent key derivation across sessions without requiring the user to re-enter credentials on every page load.
const VAULT_SALT_KEY = '__secure_vault_salt';
const ITERATIONS = 310_000;
async function deriveVaultKey(passcode: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passcode),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, // extractable: false prevents raw key bytes from leaking to JS memory
['encrypt', 'decrypt']
);
}
2. Per-Value Encryption Pipeline
Reusing salts or initialization vectors (IVs) across multiple encryption operations introduces pattern analysis vulnerabilities. Each stored value must be encrypted with a unique 12-byte IV and a 16-byte salt. The resulting binary format concatenates these components with the ciphertext and authentication tag:
[salt(16)] [iv(12)] [ciphertext] [tag(16)]
async function encryptPayload(plaintext: string, key: CryptoKey): Promise<Uint8Array> {
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
);
// Combine salt, iv, and ciphertext into a single Uint8Array
const result = new Uint8Array(salt.length + iv.length + encryptedBuffer.byteLength);
result.set(salt, 0);
result.set(iv, 16);
result.set(new Uint8Array(encryptedBuffer), 28);
return result;
}
3. Decryption & Storage Abstraction
Decryption reverses the process. The stored binary blob is sliced to extract the salt, IV, and ciphertext. The Web Crypto API automatically verifies the GCM authentication tag during decryption, failing silently if tampering is detected.
async function decryptPayload(blob: Uint8Array, key: CryptoKey): Promise<string> {
const salt = blob.slice(0, 16);
const iv = blob.slice(16, 28);
const ciphertext = blob.slice(28);
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return new TextDecoder().decode(decryptedBuffer);
}
4. Vault Lifecycle & Memory Safety
The vault should never hold raw key material in JavaScript variables. By setting extractable: false, the CryptoKey object acts as a handle to the key material held exclusively within the browser's cryptographic engine. When the vault locks, the reference is dropped, and the engine purges the key from secure memory.
class SecureVault {
private key: CryptoKey | null = null;
private idleTimer: number | null = null;
private syncChannel: BroadcastChannel;
constructor(private storage: Storage, private idleTimeoutMs: number) {
this.syncChannel = new BroadcastChannel('vault_sync');
this.syncChannel.onmessage = (e) => {
if (e.data.type === 'LOCK') this.lock();
};
}
async unlock(passcode: string): Promise<void> {
let salt = this.storage.getItem(VAULT_SALT_KEY);
if (!salt) {
const newSalt = crypto.getRandomValues(new Uint8Array(16));
this.storage.setItem(VAULT_SALT_KEY, btoa(String.fromCharCode(...newSalt)));
salt = btoa(String.fromCharCode(...newSalt));
}
const saltBytes = Uint8Array.from(atob(salt), c => c.charCodeAt(0));
this.key = await deriveVaultKey(passcode, saltBytes);
this.resetIdleTimer();
}
lock(): void {
this.key = null;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.syncChannel.postMessage({ type: 'LOCK' });
}
private resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = window.setTimeout(() => this.lock(), this.idleTimeoutMs);
}
}
Architecture Rationale
- PBKDF2 over Argon2: Argon2 offers superior resistance to GPU/ASIC cracking but requires WebAssembly, adding ~50KB to the bundle and introducing WASM loading complexity. PBKDF2 is natively supported across all modern browsers, requires zero dependencies, and meets OWASP 2024 standards when configured correctly.
extractable: false: Prevents heap dump attacks. Even if an attacker captures memory, they cannot extract raw key bytes from theCryptoKeyobject.- Per-value IV/Salt: Eliminates ciphertext pattern recognition. Identical plaintexts produce entirely different ciphertexts.
- BroadcastChannel: Provides native, event-driven cross-tab synchronization without polling or
storageevent latency.
Pitfall Guide
1. Storing the Derived Key in Browser Storage
Explanation: Developers often cache the CryptoKey or its raw bytes in localStorage to avoid re-derivation. This defeats the entire purpose of encryption, as the key becomes accessible to any origin script.
Fix: Never persist cryptographic keys. Derive them on unlock, hold them in memory via extractable: false, and purge references on lock or idle timeout.
2. Insufficient PBKDF2 Iterations
Explanation: Using default or low iteration counts (e.g., 10,000) reduces derivation time to milliseconds, enabling automated brute-force tools to test thousands of passwords per second. Fix: Adhere to OWASP 2024 guidelines. Use β₯ 310,000 iterations for PBKDF2-SHA-256. Benchmark on target devices to ensure UX remains acceptable (~1s derivation).
3. Reusing Initialization Vectors
Explanation: AES-GCM requires a unique IV for every encryption operation under the same key. Reusing an IV compromises confidentiality and allows attackers to recover plaintext via XOR analysis.
Fix: Generate a fresh 12-byte IV using crypto.getRandomValues() for every encrypt call. Never derive IVs from predictable sources.
4. Assuming Encryption Neutralizes Active XSS
Explanation: Client-side encryption protects data at rest. If an XSS payload executes while the vault is unlocked, the attacker can call decryption methods directly and receive plaintext. Encryption does not sandbox the origin. Fix: Treat encryption as a defense-in-depth layer. Implement strict Content Security Policy (CSP), sanitize all user inputs, and minimize the unlocked window duration.
5. Blocking the Main Thread During Derivation
Explanation: PBKDF2 at 310k iterations is computationally intensive. Running it synchronously on the main thread freezes the UI, causing poor UX and potential browser intervention.
Fix: Offload key derivation to a Web Worker. Pass the passcode and salt via postMessage, return the CryptoKey handle, and keep the UI responsive.
6. Ignoring Cross-Tab State Synchronization
Explanation: Users frequently open multiple tabs. If one tab locks the vault due to inactivity, others remain unlocked, creating inconsistent security states and potential data leakage.
Fix: Use BroadcastChannel to broadcast lock events. All active tabs must listen and synchronize their vault state immediately.
7. Failing to Handle Decryption Failures Gracefully
Explanation: GCM authentication tags verify integrity. If ciphertext is corrupted or tampered with, subtle.decrypt throws. Unhandled exceptions crash the application or expose stack traces.
Fix: Wrap decryption in try/catch blocks. Return null or a safe fallback state on failure. Log security events to a monitoring service without exposing sensitive details.
Production Bundle
Action Checklist
- Verify browser support: Chrome/Edge 89+, Firefox 86+, Safari 15+ (Web Crypto API baseline)
- Configure PBKDF2 iterations to β₯ 310,000 and benchmark derivation time on target hardware
- Implement
extractable: falsefor allCryptoKeyimports to prevent heap dump exfiltration - Generate unique 16-byte salts and 12-byte IVs per encryption operation using
crypto.getRandomValues() - Set up
BroadcastChannellisteners for cross-tab lock synchronization - Implement idle timeout logic with automatic vault locking and memory reference nullification
- Offload key derivation to a Web Worker to prevent main thread blocking
- Add graceful error handling for GCM authentication failures and corrupted storage blobs
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Public-facing app with low-sensitivity data | Plaintext or session-only storage | Encryption overhead outweighs threat model | Zero |
| Internal tool handling PII/financial data | Production-grade client encryption (PBKDF2 310k + AES-256-GCM) | Mitigates DevTools/XSS scraping, meets compliance | ~1s UX delay on unlock, +15KB bundle |
| High-security enterprise app | Server-side key management + client encryption | Zero-trust architecture, key never touches client | Infrastructure cost, network latency |
| Offline-first PWA | Client encryption with IndexedDB adapter | Maintains security without network dependency | Slightly larger storage footprint per value |
Configuration Template
// vault.config.ts
export interface VaultConfig {
storage: Storage;
idleTimeoutMs: number;
maxFailedAttempts?: number;
lockoutAction?: 'wipe' | 'backoff' | 'throw';
workerPath?: string; // Path to Web Worker for derivation
}
export const DEFAULT_VAULT_CONFIG: VaultConfig = {
storage: localStorage,
idleTimeoutMs: 900_000, // 15 minutes
maxFailedAttempts: 5,
lockoutAction: 'backoff',
workerPath: '/workers/vault-derivation.js'
};
// Usage
import { SecureVault } from './vault';
import { DEFAULT_VAULT_CONFIG } from './vault.config';
const vault = new SecureVault(DEFAULT_VAULT_CONFIG);
Quick Start Guide
- Initialize the Vault: Import the
SecureVaultclass and pass your storage adapter and configuration. EnsurelocalStorageorsessionStorageis available in the execution context. - Unlock with Passcode: Call
vault.unlock(passcode). The vault derives the key using PBKDF2-SHA-256 at 310k iterations, caches theCryptoKeyhandle in memory, and starts the idle timer. - Store & Retrieve Data: Use
vault.encryptItem(key, value)andvault.decryptItem(key)to interact with storage. Values are automatically serialized, encrypted with unique IVs/salts, and stored in the configured storage adapter. - Handle Locking: The vault auto-locks after the configured idle timeout. Call
vault.lock()manually on logout or sensitive action completion. All open tabs synchronize viaBroadcastChannel. - Deploy & Monitor: Ship the bundle. Monitor Web Crypto API availability and derivation performance. Implement fallback UI for unsupported browsers or slow devices.
