Generating the key
Clicking "Generate AES-256 key" calls the browser's CSPRNG for 32 truly random bytes — the same entropy source covered in the Random Number Generation module. There's no separate "key schedule" step yet; AES derives its round keys from this value internally, only once you actually encrypt.
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable — lets exportKey() read it, for display below
["encrypt", "decrypt"] // allowed key usages
);
// exported only so the tool can show it to you as hex — a real
// application would normally leave a generated key non-extractable
const raw = await crypto.subtle.exportKey("raw", key);length: 256 picks AES-256 over AES-128; extractable: true is what makes exportKey() work at all — set it false in production code that never needs to display or move the raw key.
Encrypting: counter mode underneath
GCM builds a counter block J0 from the random 96-bit IV you see displayed, with a 32-bit counter appended starting at 1. Each 128-bit block of your plaintext is XORed with AES(K, J0 + i), and the counter increments for every block — exactly the CTR mode covered in the AES module. This is also why the ciphertext is exactly as long as the plaintext: GCM never pads.
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh 96-bit nonce every call
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, // tagLength omitted → defaults to the full 128-bit tag
key,
new TextEncoder().encode(plaintext)
);
// ciphertext = actual ciphertext bytes, with the 16-byte auth tag appended at the endiv must be 12 bytes and never reused with the same key — WebCrypto doesn't stop you from reusing one, so that discipline is on the caller. tagLength defaults to 128 bits if you don't pass it.
Authenticating: GHASH and the tag
While the ciphertext is produced, GCM separately runs it through GHASH — built entirely from multiplication in the finite field GF(2¹²⁸), keyed by H = AES(K, 0¹²⁸). Each ciphertext block is folded in one at a time (Horner's method), so the final result depends on every bit of the ciphertext, in order.
The 16-byte tag appended to your ciphertext is this GHASH output XORed with AES(K, J0) — the same counter-mode building block used for encryption, applied once more to seal the tag.
// This is the exact same line shown in the step above — there is no
// separate WebCrypto method for GHASH. The browser's native AES-GCM
// implementation computes it internally and appends the 16-byte tag
// to whatever encrypt() returns.
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintextBytes
);WebCrypto never exposes GHASH as its own primitive, so there's nothing to call directly — it's folded into AES-GCM's single native implementation, invisible from JavaScript.
What one click of "Encrypt" actually runs
- 1
Build J0
The random IV plus a starting counter of 1.
- 2
Encrypt via counter mode
Each block XORed with AES(K, J0 + i) to produce ciphertext.
- 3
Fold ciphertext through GHASH
Every ciphertext block multiplied into a running value in GF(2¹²⁸).
- 4
Seal the tag
GHASH output XORed with AES(K, J0) — the 16 bytes shown as the auth tag.
GHASH folds in one ciphertext block at a time.
The same thing, as a block diagram
The encryption path (top) and the authentication path (bottom) run side by side — the ciphertext produced on top feeds directly into the GHASH chain on the bottom, and the tag is what comes out the other end.
GCM: counter-mode encryption + GHASH authentication
Shown for a single plaintext block; longer messages chain more ciphertext blocks through the same GHASH multiplication before the final XOR that produces the tag.
Why the tamper button breaks decryption
"Tamper with 1 byte" flips 8 bits of ciphertext before decryption. Multiplication in GF(2¹²⁸) has the same avalanche property as a hash function — one changed input bit changes GHASH's output completely and unpredictably. Your browser recomputes the tag from the (now tampered) ciphertext, compares it to the 16 bytes that travelled alongside it, finds no match, and crypto.subtle.decrypt() throws rather than returning any plaintext at all, even a single correct byte.
if (tamper) bytes[0] = bytes[0] ^ 0xff; // flip every bit of the first byte
try {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv }, // must be the exact IV used to encrypt
key,
bytes // ciphertext + tag, tampered or not
);
// ...success: tag verified, plaintext is real
} catch {
// OperationError — the tag didn't verify, no plaintext is ever produced
}The IV isn't a secret — it travels alongside the ciphertext — but it must be the same 12 bytes used to encrypt. GCM authenticates the whole ciphertext, so decrypt() either returns the full, correct plaintext or throws; there's no partial result.