cryptography
What "AES Encrypt" Actually Does in Your Browser
A password is not a key. Walking the three layers between a typed passphrase and a base64 blob — PBKDF2 key derivation, an AES-GCM nonce that must never repeat, and a payload format with no version byte — including why NIST calls IV reuse almost as bad as leaking the key.
Type a passphrase into any “AES encrypt” box, paste in some text, and you get back a wall of base64. It looks like one operation. It is at least three, and each one has a way to be done badly that produces output looking exactly as convincing as output done well. AES-256 takes a 256-bit key; your passphrase is not one, and everything interesting happens in the gap between those two facts.
Here is the whole pipeline, using our AES encrypt/decrypt tool as the worked example — its exact parameters are named throughout, including the one that is behind current guidance.
Layer 1: turning a password into a key
AES has no notion of a password. It takes a fixed-length key of exactly 128, 192, or 256 bits. So the first job is a key derivation function, and the tool’s is:
crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
Four decisions are packed into that call.
The salt is 16 random bytes, fresh for every encryption. Salt is not secret — it ships in the output — and it does not make any single password harder to guess. What it does is make attacks non-amortizable: without it, one precomputed table of derived keys would attack every ciphertext ever produced by the tool at once. With it, an attacker’s work applies to exactly one message.
The iteration count is the cost knob, and it is the parameter where this tool is behind current guidance. OWASP’s Password Storage Cheat Sheet currently recommends 600,000 iterations for PBKDF2-HMAC-SHA256 (and 220,000 for PBKDF2-HMAC-SHA512). This tool uses 100,000. That is a real gap, worth naming plainly rather than burying: it means an attacker brute-forcing passwords against a payload from this tool gets roughly six times as many guesses per second as they would against a 600,000-iteration payload.
The cost of the fix is trivial — measured under Node 22 on an Apple-silicon laptop:
| Iterations | Time to derive one key |
|---|---|
| 100,000 | 20 ms |
| 600,000 | 111 ms |
Ninety milliseconds. So why hasn’t it simply changed? Because of a format decision covered in Layer 3, and it is the most transferable lesson in this post: the payload carries no record of its own KDF parameters. Decryption re-derives the key by re-running PBKDF2 with hardcoded settings, so raising the iteration count would silently make every previously produced ciphertext undecryptable — with the same “wrong password” error as an actual wrong password. Any change here requires a versioned payload format first. Design that in from the start; it is nearly free on day one and expensive afterward.
PBKDF2 itself is the conservative choice, not the best one. OWASP’s ordering is Argon2id first (“a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism”), then scrypt, then bcrypt for legacy systems, with PBKDF2 recommended specifically when “FIPS-140 compliance is required.” The reason is memory-hardness: PBKDF2 is cheap to parallelize on a GPU, while Argon2id and scrypt deliberately demand memory that GPUs don’t have in quantity. The practical constraint in a browser is that WebCrypto ships PBKDF2 natively and does not ship Argon2id — using it means shipping a WASM build to every visitor. That is a defensible trade for a browser tool and a bad one for a password database.
The derived key is non-extractable. The false argument means the browser will use the key for encryption but will not hand its bytes back to JavaScript. It doesn’t stop anyone who controls the page, but it does mean the key never sits in a JS variable waiting to be logged or serialized.
Layer 2: AES-GCM, and the number that must never repeat
With a 256-bit key in hand, the actual encryption:
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes);
GCM is an authenticated mode: it encrypts and simultaneously produces an authentication tag, so tampering is detected rather than silently decrypting into garbage. WebCrypto appends the tag to the ciphertext, which you can confirm by measuring — a 14-byte plaintext comes back as 30 bytes, exactly 16 bytes of overhead, and requesting tagLength: 96 produces 12 bytes of overhead instead. The 128-bit default is the right one; don’t truncate it.
The 12-byte IV is the part to take seriously. NIST SP 800-38D recommends “that implementations restrict support to the length of 96 bits” — 12 bytes — because that length is used directly as the pre-counter block, with no extra hashing step. And then it states the requirement that gives GCM its sharpest edge:
“The probability that the authenticated encryption function ever will be invoked with the same IV and the same key on two (or more) distinct sets of input data shall be no greater than 2^-32.”
The document does not treat this as a nicety. “In practice, this requirement is almost as important as the secrecy of the key.” Appendix A explains why: repeat an IV under one key and “it is likely that an adversary will be able to determine the hash subkey from the resulting ciphertexts,” at which point they can forge a valid tag for any ciphertext they like — “the authentication assurance essentially is lost.” Worse, losing authentication re-exposes the underlying counter-mode malleability, where flipping a ciphertext bit flips the corresponding plaintext bit. Nonce reuse in GCM does not degrade security gradually. It removes it.
Section 8.3 puts a hard ceiling on random IVs: with randomly generated IVs, “the total number of invocations of the authenticated encryption function shall not exceed 2^32 … with the given key.” That bound is why long-lived-key systems use a counter-based IV construction rather than a random one.
Here the tool’s design gets a genuine reprieve, and for a reason worth understanding rather than assuming. Because a fresh random salt is generated for every encryption, every message is encrypted under a different derived key, even with the same password. The 2^32 budget is per key, and each key here is used exactly once. Two messages that happened to draw the same IV would still be under different keys, so the reuse condition never arises. Encrypting the same text twice with the same password therefore yields two completely different outputs — that is correct behavior, not a bug, and any tool that returns identical ciphertext for identical input is doing something you should look into.
Layer 3: the payload
Encryption output is three pieces concatenated and base64-encoded:
[ 16-byte salt ][ 12-byte IV ][ ciphertext ‖ 16-byte GCM tag ]
Decryption slices at exactly those offsets: bytes 0–15 are the salt, 16–27 the IV, 28-onward the ciphertext-plus-tag. A payload shorter than 28 bytes is rejected before any crypto runs.
The overhead is a fixed 44 bytes plus base64’s 4/3 expansion. A 14-character message becomes 58 bytes, which is 80 base64 characters — so short messages are dominated by their header. That is fine and unavoidable; the salt and IV both have to travel with the ciphertext.
What the format is missing is the thing Layer 1 already ran into: no version byte, no algorithm identifier, no KDF parameters. Every one of those values is implied by the code that happens to be running. The fix, if you are designing something like this today, is a single leading byte declaring the format version, and — if you might ever tune it — the iteration count alongside the salt. Neither is secret; both are what let you change your mind later.
What this actually protects you from
Being precise about the threat model matters more than the algorithm choice:
| Protects against | Does not protect against |
|---|---|
| Someone reading the ciphertext without the password | Someone who has the password |
| Undetected modification of the ciphertext (GCM tag) | A weak password — PBKDF2 slows guessing, it doesn’t stop it |
| Precomputed attacks across many payloads (the salt) | Malware or a keylogger on the machine doing the typing |
| Accidental disclosure of a pasted blob | Sending the payload and the password over the same channel |
Two consequences fall out of the mechanics.
A wrong password and a tampered ciphertext are indistinguishable. Both produce a failed GCM tag check, which is why the tool reports “Decryption failed — wrong password or corrupted data.” That is not vagueness; distinguishing the two is not possible, and any implementation claiming to tell you which one it was is leaking information it shouldn’t.
Password strength is the binding constraint. PBKDF2 at any iteration count multiplies the attacker’s cost by a fixed factor; it doesn’t change the size of the search space. A five-word passphrase and a clever-looking eight-character password are not in the same universe, for reasons password entropy explained works through in detail.
One browser-level note: crypto.subtle is only available in a secure context — HTTPS or localhost. If a page’s crypto silently fails on an internal HTTP host, that is usually why, not the code.
When to reach for this, and when not to
Good fits: encrypting a note before pasting it into a ticket, a chat, or a shared document; sending a config snippet to a colleague when you can pass the passphrase through a different channel; keeping something readable-only-by-you inside a file that will sit in a place you don’t fully control.
Bad fits: anything with more than one recipient, anything needing forward secrecy or key rotation, anything where the passphrase would have to live in the same place as the ciphertext, and anything at rest for years — the iteration-count discussion above is exactly what ages badly.
If your actual need is public-key rather than shared-passphrase, ed25519 vs RSA for SSH keys and the key pair generator are the other branch, and how HMAC works covers authentication without encryption — the case where you want to prove a message wasn’t altered but don’t need it hidden.
The short version
| The assumption | The mechanism |
|---|---|
| ”It encrypts with my password” | The password feeds PBKDF2 (SHA-256, 100,000 iterations, 16-byte random salt) to derive a 256-bit AES key |
| ”More iterations would be strictly better” | Yes — OWASP says 600,000, costing ~90 ms more — but the payload records no KDF parameters, so raising it breaks every old ciphertext |
| ”Reusing an IV is a minor weakness” | NIST: nonce reuse likely exposes the hash subkey, enabling forgery — “the authentication assurance essentially is lost" |
| "Same input, same output” | A fresh salt and IV per encryption make every output different; identical output would be the bug |
| ”The blob is self-describing” | It’s salt‖IV‖ciphertext‖tag with no version byte — offsets are implied by the code, which is what makes formats un-upgradable |
| ”Decryption failed, so the data was tampered with” | A wrong password and a modified ciphertext fail the same tag check and cannot be distinguished |