The strongest privacy architecture is the one where the server never holds the plaintext. Browser-native WebCrypto makes AES-256-GCM + PBKDF2 available without a single dependency — here is how the pieces actually fit, and where developers still cut their fingers.
1. Why Client-Side at All: The Zero-Knowledge Argument
Every server-side encryption scheme asks the user to trust a promise. Client-side encryption replaces the promise with a property: plaintext and passwords never leave the device, so a breach of the server yields ciphertext only. For notes, backups, contract drafts, and personal records, this is the difference between "we promise we can’t read it" and "there is nothing to read".
The catch: the browser must do real cryptography, not XOR with a hex key. That is what the Web Crypto API (crypto.subtle) is for — a native, constant-time, hardware-backed implementation that no pure-JS library can match for safety.
2. AES-256-GCM: Encryption With a Lie Detector
AES-GCM is authenticated encryption: alongside the ciphertext it produces a 128-bit authentication tag. On decryption, if even one bit was flipped — or the password is wrong — the operation fails loudly instead of returning plausible garbage. Older modes like CBC decrypt tampered data happily and let your application discover the corruption the hard way.
The one rule that must never break: never reuse an IV/nonce with the same key. GCM nonces are 96 bits, generated randomly per message — with random 12-byte IVs, the collision probability only becomes meaningful after billions of messages under one key. Rotating keys or keeping PBKDF2’s random salt per encryption (which yields a fresh key every time) makes the problem vanish by construction.
AES-256-GCM Encrypt & DecryptPassword-based AES-256-GCM with PBKDF2 (150k iterations, random salt) — fully in-browser, self-contained salt:iv:ciphertext payloads.→3. PBKDF2: Turning Human Passwords Into Machine Keys
Passwords are terrible keys: low entropy, reused, guessable. PBKDF2-HMAC-SHA256 stretches them — hash the password together with a random 16-byte salt, then repeat 150,000+ times. The salt kills rainbow tables (identical passwords produce different keys); the iteration count taxes every guess an attacker makes.
What PBKDF2 cannot fix is a weak password: 150k iterations raise the cost of each guess by roughly 2¹⁷, which converts a 40-bit password into something like 57 bits of effective work — still crackable. Key stretching buys a multiplier, not a miracle; the password itself must carry most of the entropy.
// In-browser AES-256-GCM with PBKDF2 key derivation (WebCrypto)
const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'])
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 150000 },
keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
const cipher = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext))
// Ship salt:iv:cipher together — no server ever saw the password.4. Keys and Secrets: Generate Them Like a Paranoid
Any secret your code or users rely on should come from a cryptographically secure RNG — crypto.getRandomValues, never Math.random(). An API key or token generated from a predictable PRNG is not a secret; it is a puzzle.
- API keys / tokens — 128 bits minimum (32 hex chars), CSPRNG only.
- Encryption passwords — length beats complexity: 5 random words outrun 10 muddled characters.
- Salts and IVs — random per operation, stored with the ciphertext; they are not secrets, but they must be unique.
5. JWTs: Decoding Is Not Verifying
A JWT is base64url(header).base64url(payload).signature. Anyone can decode the first two segments — they are signed, not encrypted. Two consequences developers trip over: never put secrets in a JWT payload (your users can read it with any decoder), and on the server, verify the signature before trusting a single claim, with the alg allowlist pinned — the classic "alg: none" and RS256-to-HS256 confusion attacks both begin with skipping that step.
Client-side decoding is for debugging: inspect expiry, audience, and scopes in the payload you received — then verify on the server side, where the secret or public key lives.
JWT DecoderInspect header and payload locally, with expiry and claims rendered readably — decoding stays in the browser where the token already was.→The Client-Side Checklist
- Use AES-GCM (or another AEAD mode), never raw CBC/ECB.
- Derive keys with PBKDF2/Argon2-class stretching: random 16-byte salt, ≥100k iterations.
- Every secret from crypto.getRandomValues — audit every
Math.random()in security paths. - Random IV per message; transmit salt:iv:ciphertext together.
- Decode JWTs freely; verify them only with the real key, allowlisted algorithms, and unexpired timestamps.