Encrypting Notes in the Browser: AES-GCM, Key Derivation, Nonce Pitfalls
Reference: W3C Web Cryptography API · MDN: SubtleCrypto — W3C Recommendation · MDN docs (CC-BY-SA)
Encrypting notes client-side comes down to three disciplined steps. Derive a key from the user's passphrase with a deliberately slow function — PBKDF2 or Argon2id. Encrypt each note with AES-GCM and a fresh 96-bit nonce. Store the salt and nonce next to the ciphertext, and never store the key or reuse the nonce. Everything hard lives in those two "nevers."
The browser gives you all of this natively through SubtleCrypto, the interface behind window.crypto.subtle. No library required for the core path. But a correct call and a fatal one look almost identical on screen, and the difference is invisible until an attacker finds it. The W3C spec that defines this API says so plainly: "application developers are strongly discouraged from inventing new cryptographic protocols."1 This post is the opposite of invention. It is the boring, correct path, grounded in the specs that define it, with working code, a measured cost table, and the one mistake that undoes all the others.
What does correct browser encryption actually require?
Correct client-side encryption needs three moving parts working together: a key-derivation function that turns a weak passphrase into a strong key slowly, an authenticated cipher that both hides and tamper-proofs the data, and a unique nonce for every single encryption. Miss any one and the whole scheme degrades — usually silently, with no error and no warning.
The browser exposes each part through SubtleCrypto. Key derivation is crypto.subtle.deriveKey; encryption is crypto.subtle.encrypt with { name: "AES-GCM" }; the nonce is the iv you pass in. The API is small on purpose. What it does not do is stop you from wiring the parts together wrongly — passing a constant nonce, storing the key beside the ciphertext, or stretching the passphrase with a single hash instead of hundreds of thousands. The spec is explicit that the API alone guarantees nothing: to get "any meaningful cryptographic assurances, authors must be familiar with existing threats to web applications, as well as the underlying security model employed."1 The building blocks are correct; the assembly is on you.
Why is reusing a nonce catastrophic?
Reusing an AES-GCM nonce with the same key is the single worst mistake here, because it does not just weaken confidentiality — it destroys the authentication that makes GCM safe. NIST states the rule directly: "if even one IV is ever repeated, then the implementation may be vulnerable to the forgery attacks."2 One repeat. Not a pattern of repeats. One.
Here is the wrong-but-common version. It looks tidy and it passes every test you write, because tests use known inputs and never notice that the nonce never changes:
// FATAL: a constant IV. Every note is encrypted with the same nonce.
const iv = new Uint8Array(12); // 12 zero bytes, reused forever
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
Why is this so much worse than a normal cipher leak? AES-GCM is a stream-style mode: it turns the key and nonce into a keystream and XORs it over your plaintext. Reuse the nonce and two ciphertexts share a keystream, so their XOR is the XOR of two plaintexts — classic two-time-pad territory. But GCM adds a second failure on top. NIST's own analysis spells it out: "if IVs are ever repeated for the GCM authenticated encryption function for a given key, then it is likely that an adversary will be able to determine the hash subkey from the resulting ciphertexts. The adversary then could easily construct a ciphertext forgery."2 Recovering that subkey lets an attacker forge valid authentication tags for messages you never wrote. The tamper-proofing you added GCM for evaporates. NIST ranks the stakes bluntly: nonce uniqueness "is almost as important as the secrecy of the key."2
The fix is one line: generate a fresh random nonce for every encryption, from the browser's CSPRNG.
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh 96-bit nonce
Why 96 bits, and how many messages is safe?
Use a 12-byte (96-bit) nonce, generated fresh per encryption. NIST recommends implementations "restrict support to the length of 96 bits, to promote interoperability, efficiency, and simplicity of design."3 With random 96-bit nonces, the safety ceiling is how many messages one key encrypts before a birthday-bound collision grows likely — far beyond what a note store reaches, but not infinite.
The precise limit matters for anything at scale. NIST caps a single key at "2^32" invocations of the authenticated-encryption function, and requires that the probability of ever reusing a nonce with a key "shall be no greater than 2^-32."3 For random 96-bit nonces, collision risk grows with the birthday bound — roughly the square of the message count over the nonce space — so the practical guidance is to stay comfortably under about 2^32 messages per key and to rotate keys rather than push that boundary. For a note app that encrypts one blob per note, or one blob per save, you will never approach it. The discipline that matters is not the ceiling; it is that the nonce is fresh every time, from getRandomValues, never a counter you forgot to increment and never a constant.
One more reassurance from MDN, because developers routinely over-protect the nonce: "the IV does not have to be secret, just unique: so it is OK, for example, to transmit it in the clear alongside the encrypted message."4 Store it next to the ciphertext. Do not encrypt it, do not hide it, and do not — the whole point — reuse it.
The architecture, from passphrase to stored ciphertext
The correct flow is a straight line with two inputs of randomness. A passphrase becomes a slow-derived key using a random salt; a fresh nonce and the plaintext feed AES-GCM; the salt and nonce are stored beside the ciphertext so decryption can rebuild the key. The key is never stored — it is re-derived from the passphrase on demand.
flowchart TD
A[Passphrase] --> B[Import as<br/>PBKDF2 key]
S[Random salt<br/>16 bytes] --> C[Derive AES-GCM<br/>256-bit key]
B --> C
C --> E[AES-GCM<br/>encrypt]
D[Fresh 96-bit<br/>nonce per note] --> E
P[Plaintext note] --> E
E --> F[Ciphertext<br/>+ auth tag]
F --> G[Store salt +<br/>nonce + ciphertext]
Figure: the client-side encryption path. The passphrase and a random per-user salt derive a 256-bit AES-GCM key; that key, the plaintext note, and a fresh 96-bit nonce produce ciphertext plus an authentication tag; the salt and nonce are stored in the clear alongside the ciphertext, while the key is never persisted — it is re-derived from the passphrase on demand.
Two properties fall out of this shape. The salt makes every user's derived key different even if two people pick the same passphrase, which defeats precomputed rainbow-table attacks. The per-note nonce makes every ciphertext independent, which is what keeps AES-GCM's guarantees intact across a whole notebook. Both are stored in the open; neither is a secret. The only secret in the system is the passphrase in the user's head — and, briefly, the key derived from it, held in memory and thrown away when the session ends.
How should you derive the key — PBKDF2 or Argon2id?
Derive the key from the passphrase with a function tuned to be slow, so brute-forcing guesses is expensive. Two real choices exist in the browser: PBKDF2, which SubtleCrypto supports natively, and Argon2id, which is stronger but needs a WebAssembly library because the native API does not include it. The W3C algorithm registry lists PBKDF2 and HKDF; Argon2 is absent.1
PBKDF2 is iteration-hard — it makes each guess cost more CPU. OWASP's current guidance is "PBKDF2-HMAC-SHA256: 600,000 iterations" or "PBKDF2-HMAC-SHA512: 220,000 iterations."5 Here is the native call:
// PBKDF2 → a 256-bit AES-GCM key, entirely with SubtleCrypto
const baseKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]
);
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 600_000, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false, // not extractable — the key can't be read back out
["encrypt", "decrypt"]
);
Argon2id is memory-hard — it forces each guess to occupy a large block of RAM, which is what defeats GPU and ASIC brute-forcing that PBKDF2 cannot. RFC 9106 names Argon2id the primary variant and recommends, as its first option, "t=1 iteration, p=4 lanes, m=2^(21) (2 GiB of RAM), 128-bit salt, and 256-bit tag size," with a lighter "t=3 iterations, p=4 lanes, m=2^(16) (64 MiB of RAM)" second option for constrained environments.6 In a browser you reach it through a WASM build such as argon2-browser or hash-wasm, then hand the raw bytes to importKey as an AES-GCM key. The trade-off is honest: Argon2id resists specialized hardware far better, at the cost of a heavier dependency and real memory pressure on low-end phones. OWASP's tie-breaker is worth memorizing: prefer Argon2id in general, but "since PBKDF2 is recommended by NIST and has FIPS-140 validated implementations, it should be the preferred algorithm when these are required."5
How slow is slow enough? A measured KDF table
Iteration counts only mean something as latency on real hardware, so I benchmarked PBKDF2 through the same WebCrypto interface a browser exposes. The number to anchor on: PBKDF2-HMAC-SHA256 at OWASP's recommended 600,000 iterations took a 149.4 ms median on this machine. SHA-512 at 600,000 took 349.6 ms — which is exactly why OWASP sets SHA-512's count lower.
Measured with crypto.webcrypto.subtle.deriveKey on Node v20.19.5 (an AMD EPYC 9354P), median of 7 runs after one warmup, 16-byte salt:
| Hash | Iterations | Median derive time |
|---|---|---|
| SHA-256 | 100,000 | 25.6 ms |
| SHA-256 | 210,000 | 57.5 ms |
| SHA-256 | 600,000 | 149.4 ms |
| SHA-256 | 1,000,000 | 236.7 ms |
| SHA-512 | 210,000 | 134.6 ms |
| SHA-512 | 600,000 | 349.6 ms |
| SHA-512 | 1,000,000 | 610.8 ms |
Two things to read off it. Cost scales roughly linearly with iterations, so doubling the count doubles both the attacker's expense and the user's unlock wait — that is the whole lever. And SHA-512 costs about 2.3 to 2.6 times more per iteration than SHA-256 here, which is the reason OWASP's SHA-512 recommendation (220,000) sits well below its SHA-256 one (600,000).5 The critical caveat: this is a server-class CPU. A mid-range phone is materially slower, so calibrate iterations against your slowest target device, aiming for a derive time users tolerate on unlock — a few hundred milliseconds — not a number copied from a blog running on a workstation.
The correct encrypt and decrypt, end to end
With a derived key, encryption is a fresh nonce and one call; decryption reverses it and verifies the tag automatically. AES-GCM authenticates on decrypt: if a single byte of ciphertext, nonce, or tag was altered, decrypt rejects the whole message by throwing, rather than handing back corrupted plaintext. You get tamper-detection for free — provided the nonce was unique.
async function encryptNote(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh per note
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext)
);
return { iv, ciphertext: new Uint8Array(ct) }; // store both, in the clear
}
async function decryptNote(key, iv, ciphertext) {
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(pt); // throws if the tag fails — tamper detected
}
Notice what is stored and what is not. The iv and the salt travel with the ciphertext, unencrypted; that is safe and required, because decryption needs both. The key is absent — it is re-derived from the passphrase when the user returns, then discarded. Wrap the decrypt in a try/catch: a thrown error is not a bug, it is the authentication tag doing its job on modified or truncated data.
The classic browser-crypto mistakes to never ship
Most client-side encryption failures are not exotic — they are the same handful of mistakes repeated. Each one turns a scheme that looks encrypted into one that is not, with no visible symptom. Learn them as a checklist and grep your own code for them before you ship.
- A static or predictable IV. The single fatal error above — one repeated nonce breaks GCM's authentication entirely.2 Always
getRandomValuesa fresh nonce. - AES-ECB, or any unauthenticated mode. ECB encrypts identical blocks identically, leaking structure; CBC without a MAC has no tamper detection. AES-GCM is authenticated — use it.
- Home-rolled key stretching. A single
SHA-256(passphrase)is not key derivation; it is one guess-per-hash for an attacker. UsederiveKeywith hundreds of thousands of PBKDF2 iterations, or Argon2id.5 - Storing the key next to the ciphertext. Persisting the derived key — in local storage, in a cookie, in the record beside the data — hands the attacker everything. Store the salt and nonce; re-derive the key.
- Reusing one salt for every user. A shared or empty salt re-enables precomputed attacks. Generate a random salt per user and store it with their data.
What client-side encryption does and does not protect
Client-side encryption protects one thing: your note data at rest, so that someone who reads the raw stored bytes — a stolen device backup, another app poking at storage, a synced blob on a server — sees only ciphertext without the passphrase. That is real and worth doing. It is also the entire scope, and honest engineering names the edges.
What it does not protect: anything that runs inside the authenticated page. If an attacker can execute script in your origin — a cross-site-scripting hole, a compromised dependency, a malicious extension — they run with the same access the user has. They can read the passphrase as it is typed and the derived key while it lives in memory, because at that moment the data is decrypted for legitimate use. Encryption at rest cannot defend a runtime that has already been subverted; that is what a Content Security Policy, dependency hygiene, and Subresource Integrity are for. This is why the W3C spec insists that meaningful assurance requires being "familiar with existing threats to web applications."1 State this scope wherever you make an encryption claim: it protects stored data against someone reading storage; it does not make a compromised page safe, and no browser cipher does. The same open-format, own-your-data thinking behind keeping notes as encrypted files you control applies here — encryption is a property you have to scope, not a badge you get to wear.
Verdict
For encrypting notes in the browser, the correct recipe is settled and native: derive with PBKDF2 (or Argon2id via WASM when you can afford the weight), encrypt with AES-GCM, and give every message a fresh 96-bit nonce. The cryptography is not the hard part — SubtleCrypto and the standards did that work. The hard part is the discipline around it: never reuse a nonce, never store the key, always benchmark your KDF on the weakest device, and always state what the encryption does not cover. Get those right and the primitives hold. Get the nonce wrong once and they do not.
Frequently Asked Questions
Does the nonce (IV) need to be secret in AES-GCM?
No. The nonce must be unique per encryption with a given key, but it does not need to be secret. MDN states it directly: "the IV does not have to be secret, just unique: so it is OK, for example, to transmit it in the clear alongside the encrypted message."4 Store it next to the ciphertext; the only rule is never to reuse it under the same key.
Can I reuse the same IV for AES-GCM if the data is different?
No — this is the one fatal mistake. NIST warns that "if even one IV is ever repeated, then the implementation may be vulnerable to the forgery attacks."2 Reuse leaks the GCM hash subkey, letting an attacker forge authentication tags and defeat the tamper protection entirely. Always generate a fresh nonce with crypto.getRandomValues.
Should I use PBKDF2 or Argon2id in the browser?
SubtleCrypto supports PBKDF2 natively, so it needs no dependency; Argon2id is stronger against GPU and ASIC attacks but requires a WebAssembly library because the browser API does not include it.1 Prefer Argon2id when you can afford the dependency and memory; OWASP notes PBKDF2 is preferable "when FIPS-140 compliance is required."5
How many PBKDF2 iterations should I use?
OWASP currently recommends 600,000 iterations for PBKDF2-HMAC-SHA256, or 220,000 for SHA-512.5 Treat those as a floor and tune to your slowest target device: in my benchmark, 600,000 SHA-256 iterations took about 149 ms on a fast CPU, but a low-end phone is materially slower. Aim for a few hundred milliseconds on unlock.
Where do I store the salt and nonce?
Store both in the clear, alongside the ciphertext — they are inputs to decryption, not secrets. The salt lets you re-derive the same key from the passphrase; the nonce lets AES-GCM reverse the encryption. Never store the derived key itself; re-derive it from the passphrase each session and discard it when done.
Is client-side encryption enough to protect my notes?
It protects data at rest — the stored bytes are unreadable without the passphrase. It does not protect a compromised page: script running in your origin (via XSS, a bad dependency, or a malicious extension) can read the passphrase and the in-memory key while data is legitimately decrypted. Pair encryption with a Content Security Policy and dependency hygiene.
References
Cryptography rarely fails loudly. It fails the day someone reuses a nonce and nothing on screen changes.
If you want a place to keep notes that stay on your own device, mnmnote.com runs in your browser, offline, with end-to-end encrypted sharing.
Footnotes
-
"Web Cryptography API," W3C Recommendation, W3C, https://www.w3.org/TR/WebCryptoAPI/, accessed 24 July 2026. ↩ ↩2 ↩3 ↩4 ↩5
-
Morris Dworkin, "Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM) and GMAC," NIST Special Publication 800-38D, November 2007, §8 and Appendix A, https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf, accessed 24 July 2026. ↩ ↩2 ↩3 ↩4 ↩5
-
NIST SP 800-38D, §5.2.1.1 and §8.3, https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf, accessed 24 July 2026. ↩ ↩2
-
"AesGcmParams," MDN Web Docs, Mozilla, https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams, accessed 24 July 2026. ↩ ↩2
-
"Password Storage Cheat Sheet," OWASP Cheat Sheet Series, https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, accessed 24 July 2026. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Alex Biryukov, Daniel Dinu, Dmitry Khovratovich & Simon Josefsson, "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications," RFC 9106, IRTF CFRG, September 2021, §4, https://www.rfc-editor.org/rfc/rfc9106.html, accessed 24 July 2026. ↩