← Back to the tool

Symmetric-key · Under the hood

AES-GCM encrypt & decrypt

What each button in the tool actually computes, step by step — with the real formulas.

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.

The actual WebCrypto call
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.

The actual WebCrypto call
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 end

iv 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.

Ci=Pi⊕AES(K, J0+i)C_i = P_i \oplus \mathrm{AES}(K,\ J_0 + i)

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.

No separate call — it runs inside the same encrypt()
// 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. 1

    Build J0

    The random IV plus a starting counter of 1.

  2. 2

    Encrypt via counter mode

    Each block XORed with AES(K, J0 + i) to produce ciphertext.

  3. 3

    Fold ciphertext through GHASH

    Every ciphertext block multiplied into a running value in GF(2¹²⁸).

  4. 4

    Seal the tag

    GHASH output XORed with AES(K, J0) — the 16 bytes shown as the auth tag.

Xi=(Xi−1⊕Ci)⋅HX_i = (X_{i-1} \oplus C_i) \cdot H

GHASH folds in one ciphertext block at a time.

T=GHASH(H,C)⊕AES(K,J0)T = \mathrm{GHASH}(H, C) \oplus \mathrm{AES}(K, J_0)

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

ENCRYPTIONJ0 + 1AES_K⊕CiphertextPlaintextAUTHENTICATIONCiphertext× H (GHASH)⊕AES_K(J0)Tag

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.

The actual WebCrypto call
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.

Now try it

Back to AES-GCM encrypt & decrypt →

Encrypt a real message with real AES-256-GCM, see the IV, ciphertext, and auth tag, then decrypt it back — or tamper with one byte and watch decryption fail.