The Bug That Made My Encryption Instantly Useless
I added AES-256-GCM encryption to protect BYOK API keys in localStorage. It compiled, tests passed—but every ciphertext was immediately unrecoverable. Here's the silent bug, the fix, and what it teaches about cryptographic code.
I added AES-256-GCM encryption to protect the BYOK API keys that TopFlow stores in localStorage. The implementation compiled cleanly. Unit tests passed. The browser's Application tab showed encrypted:A4Bx... values where plaintext keys used to be. It looked exactly right.
But every ciphertext was immediately unrecoverable. On the next decrypt call — milliseconds later, in the same browser session — the operation threw. A try/catch fallback silently returned the raw encrypted:... string as if it were a plaintext API key. The AI provider rejected it. From the user's perspective: "my API key stopped working." Not: "your decryption is broken."
The bug was a single function call. Here's what happened, why AES-GCM fails this way, the fix, and what this teaches about writing and testing cryptographic code.
The Setup: BYOK Secrets in a Zero-Backend App
TopFlow is deliberately database-free. Its privacy pitch is "your keys never leave your browser." That means two kinds of long-lived, high-value credentials live in localStorage: AI provider API keys (OpenAI, Anthropic, Google, Groq) entered in the settings dialog, and GitHub tokens used by the security scanner.
Storing these in plaintext means a single glance at DevTools → Application → Storage reveals all of them. The threat isn't just a sophisticated attacker — it's a shared office laptop, a borrowed device, a screen share during a demo, or a browser extension with storage access.
The chosen control was AES-256-GCM via the browser-native Web Crypto API. GCM (Galois/Counter Mode) provides both confidentiality and integrity: a tampered ciphertext fails authentication and decryption throws, rather than silently returning garbage. Each value gets a random 96-bit IV, and the stored format is encrypted:<base64(iv ‖ ciphertext)>. A reasonable choice — if the key doesn't disappear between encrypt and decrypt.
The Bug: A Key That Lived for One Call
AES-GCM is a symmetric cipher. Encrypt with a key; decrypt with the same key. That constraint seems obvious, and it is — until a helper function hides it. The original getEncryptionKey() called crypto.subtle.generateKey() on every invocation:
// ❌ Broken: generates a fresh 256-bit key on every call
async function getEncryptionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false, // not extractable
["encrypt", "decrypt"]
)
}On encrypt, call #1 produces key A. On decrypt, call #2 produces key B. Key A and key B are entirely different 256-bit random values. The authentication tag embedded in the ciphertext was computed with key A. Verification with key B produces a different tag. They don't match. Decryption throws.
Why AES-GCM Fails This Way (and Why It's Silent)
GCM authentication works via GHASH — a polynomial MAC function keyed to the encryption key. During encrypt, it computes a tag over the ciphertext and appends it. During decrypt, it recomputes the tag and compares. A mismatched key produces a mismatched tag, and the browser throws: DOMException: The operation failed for an operation-specific reason.
There is no "wrong key but here's garbled output" mode. Authenticated encryption either passes and returns plaintext, or it throws. This is intentional — it protects against ciphertext tampering — but it means key management failures surface as exceptions, not bad data. Without a round-trip test, you won't notice until a user reports "my key stopped working."
The silent fallback made it worse. The backward-compatibility path in decryptValue() caught the exception and returned the original value unchanged — intended for pre-encryption plaintext keys during migration. But it also meant the broken encrypted:... string was returned to the caller, passed to the serverless function as an "API key," and rejected by the provider with no indication that decryption had silently failed.
The Fix: Generate Once, Cache, Persist
The key needs to be the same across every call — including across page reloads. The fix is straightforward: generate once, cache the result in a module-scope variable, and persist the raw key bytes to localStorage so the same key is recovered after a reload.
// ✅ Fixed: generate once, cache in memory, persist across sessions
let cachedKey: CryptoKey | null = null
async function getEncryptionKey(): Promise<CryptoKey> {
if (cachedKey) return cachedKey // 1. Memory cache (same tab)
let raw = loadPersistedKeyBytes() // 2. Try localStorage
if (!raw) {
raw = new Uint8Array(32) // 3. First visit: generate
crypto.getRandomValues(raw)
persistKeyBytes(raw) // Save raw bytes for later
}
cachedKey = await crypto.subtle.importKey(
"raw",
raw as Uint8Array<ArrayBuffer>,
{ name: "AES-GCM", length: 256 },
false, // not extractable
["encrypt", "decrypt"]
)
return cachedKey
}Three tiers of lookup: memory cache (same tab session), localStorage (across reloads), and fresh generation (first visit or cleared storage). The critical distinction: importKey() loads a specific key from bytes, guaranteeing the same key object is returned regardless of which call site invokes the function. generateKey() produces a fresh random key every time — that's the root cause of the bug.
The migration path is preserved: if decryptValue() receives a string that doesn't start with encrypted:, it returns it unchanged. A one-time migrateApiKeys() function runs on load to encrypt any legacy plaintext values in place.
The Test That Catches This
The broken implementation passed tests because they only verified that encryptValue() didn't throw and that the result looked different from the input. Neither check requires a stable key. The test that catches a key-custody bug is a round-trip:
// ✅ Round-trip: the only test that catches key-custody bugs
it("encrypts and decrypts to the same value", async () => {
const original = "sk-ant-my-api-key-12345"
const encrypted = await encryptValue(original)
expect(encrypted).toMatch(/^encrypted:/) // Has the prefix
expect(encrypted).not.toBe(original) // Actually transformed
const decrypted = await decryptValue(encrypted)
expect(decrypted).toBe(original) // Identity holds ← broken impl fails here
})
// Also verify IV randomness: two encryptions of the same input must differ
it("produces different ciphertexts for the same plaintext", async () => {
const val = "same-input"
const c1 = await encryptValue(val)
const c2 = await encryptValue(val)
expect(c1).not.toBe(c2)
})These two tests together prove the full contract: values are encrypted (not equal to plaintext), decryption recovers the original (identity), and each encryption is unique (IV randomness). The broken implementation would fail the identity assertion immediately — the return value of decryptValue() would be the raw encrypted:... string, not original.
The Honest Limitation: This Doesn't Stop XSS
A question worth answering directly: if the encryption key is stored in localStorage, what does encryption actually achieve?
Protects against at-rest exposure
- Casual DevTools inspection (Application tab shows ciphertext, not plaintext keys)
- Shared or borrowed device where another person opens the browser
- Screen share or demo where the storage tab is visible
- Browser extensions that read storage values without executing scripts in-page
Does not protect against
- XSS — an injected script runs in the same origin and can read both the ciphertext and the encryption key from localStorage simultaneously
- A stolen browser profile (key and ciphertext are both on disk, co-located)
- Physical access to an unlocked device with an open browser tab
The key-custody tradeoff: a client-held key raises the bar for at-rest exposure (now an attacker needs script execution, not just storage read access) but does not create a cryptographic boundary against XSS. True XSS-resistant key custody requires either a server-held key or a user passphrase (PBKDF2/Argon2) — at the cost of a server round-trip or a session unlock prompt.
For TopFlow's zero-backend architecture, a server-held key is a non-starter. A passphrase-derived key is feasible but adds friction. The chosen approach — a persisted random key — is the right tradeoff here, as long as the limitation is documented honestly. The comment at the top of lib/security/encryption.ts states it explicitly: a client-held key is not a defense against XSS. The real XSS controls live elsewhere — in the Content Security Policy, in output handling, and in the Untrusted Reasoning Worker boundary that constrains what the LLM is permitted to produce.
"Don't let 'we encrypt it' become security theater. Name the threat your control actually addresses."
Takeaways
Test the round-trip, not just the encrypt call
Verifying that encryptValue() doesn't throw and returns something different is not enough. Only an encrypt → decrypt → assert-identity test catches key-custody bugs. Add this to every cryptographic utility you write.
generateKey() and importKey() are not interchangeable
generateKey() produces a fresh random key on every call. importKey() loads a specific key from bytes. Key-stability across calls requires the latter. This distinction isn't obvious from the Web Crypto API docs.
Silent fallbacks in crypto code mask failures
The backward-compatibility catch-and-return-input path turned a decryption exception into a silent wrong-value return. Crypto failures should surface loudly, or be architected so silent fallbacks are unreachable after the migration window closes.
Name the threat your control actually addresses
'We encrypt API keys' is only meaningful if you specify: encrypt at rest, protecting against at-rest inspection, not against XSS. Precision prevents false security assumptions from accumulating.
Client-held keys are a real tradeoff, not security theater
Encryption without a server-held key still raises the bar. The alternative — plaintext storage — is strictly worse. The key is understanding and documenting exactly what the bar was raised from and to.
The full implementation — including the migration function, backward-compatibility path, and test suite — is in lib/security/encryption.ts on GitHub. The security case study behind this fix — covering the threat model, attack trees, and the key-custody tradeoff spectrum — is Tutorial 02 of the AI Security Series.
Try TopFlow's security workflows at topflow.dev — no signup, no API keys required.