AES-256-GCM in JavaScript with Web Crypto

You want to encrypt some text in the browser, get back a string you can store or send, and decrypt it later. The good news is that every modern browser ships AES-256-GCM natively through the crypto.subtle interface, so you do not need a third-party library. The Web Crypto API runs entirely in the browser, which means the plaintext and the key never have to touch a server. This guide is the practical recipe: generating a key, handling the IV correctly, deriving a key from a password with PBKDF2, and the three mistakes that quietly break real implementations.

Why AES-256-GCM specifically

AES is a symmetric cipher: the same key encrypts and decrypts. (If you need a refresher on when symmetric beats public-key crypto, read symmetric vs asymmetric encryption.) GCM stands for Galois/Counter Mode, an authenticated mode. That means it produces both ciphertext and an authentication tag, and decryption fails loudly if either the ciphertext or the tag has been tampered with. You get confidentiality and integrity in one operation. The Web Crypto API supports AES-GCM directly; it is defined in the W3C Web Cryptography specification and documented on MDN under SubtleCrypto.

Generating a key and encrypting

For a key you control programmatically, use crypto.subtle.generateKey. AES-256 means a 256-bit key. Every call to encrypt needs a fresh, random 12-byte initialization vector (IV) and a tag length of 128 bits.

async function encrypt(plaintext, key) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv, tagLength: 128 },
    key,
    encoded
  );
  // Prepend the IV so decrypt can find it later.
  const out = new Uint8Array(iv.length + ciphertext.byteLength);
  out.set(iv, 0);
  out.set(new Uint8Array(ciphertext), iv.length);
  return btoa(String.fromCharCode(...out));
}

const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

The 12-byte (96-bit) IV size is the recommended length for GCM in NIST SP 800-38D and is what GCM is optimized for. Notice the IV is stored alongside the ciphertext, not kept secret. An IV does not need to be secret; it needs to be unique for every message under the same key.

Decrypting

Decryption reverses the process: pull the first 12 bytes back off as the IV, then hand the rest to crypto.subtle.decrypt. If the data was altered, the promise rejects rather than returning garbage.

async function decrypt(b64, key) {
  const data = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
  const iv = data.slice(0, 12);
  const ciphertext = data.slice(12);
  const plainBuffer = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, tagLength: 128 },
    key,
    ciphertext
  );
  return new TextDecoder().decode(plainBuffer);
}

You do not extract or check the tag yourself. GCM embeds the authentication tag at the end of the ciphertext, and Web Crypto verifies it automatically. A failed verification throws an OperationError, so wrap the call in try/catch and treat any rejection as "this data is not trustworthy."

Deriving a key from a password with PBKDF2

A random generateKey key is great when you can store the raw key. But often you want a user's password to produce the key. You cannot use a password directly as an AES key, because passwords are low-entropy and the wrong length. PBKDF2 (Password-Based Key Derivation Function 2, defined in RFC 8018) stretches a password into a proper key using a random salt and many iterations.

async function keyFromPassword(password, salt) {
  const baseKey = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );
  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,                 // a fresh random 16-byte value, stored with the ciphertext
      iterations: 600000,   // high iteration count slows brute force
      hash: "SHA-256"
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await keyFromPassword(userPassword, salt);

Store the salt next to the ciphertext (it is not secret), and use a fresh salt per password. A high iteration count is the whole point of PBKDF2: it makes each guess expensive for an attacker. OWASP currently recommends at least 600,000 iterations for PBKDF2 with HMAC-SHA-256, and that figure has risen over time as hardware improves, so check their latest guidance rather than copying a fixed number forever. If you are evaluating the strength of the passwords feeding this, our password strength checker and password generator both run locally in your browser.

Three landmines that break real code

  • Never reuse an IV under the same key. This is the cardinal rule of GCM. Reusing a nonce with the same key can leak the plaintext relationship between messages and, worse, can let an attacker recover the authentication key and forge valid tags, collapsing GCM's integrity guarantee. Generate a fresh IV with crypto.getRandomValues for every single encrypt call. Do not derive it from a counter you might reset, and do not hardcode it.
  • Never hardcode a key in client-side JavaScript. Anything shipped to the browser is readable by anyone who opens DevTools. A key embedded in your bundle is a published key. Client-side AES is for cases where the key comes from the user (a password) or from a server over an already-secure channel, not for protecting secrets from the user running the page.
  • Do not hand-roll integrity checks. GCM is authenticated encryption. It already produces and verifies a tag. Adding your own HMAC, hash comparison, or "checksum" on top is redundant at best and, if done wrong (for example, a non-constant-time comparison), actively harmful. Trust the mode and let decrypt throw on tampering.

Try it without writing code

If you just want to encrypt or decrypt a string right now, the AES encryption tool does exactly this in your browser using Web Crypto. Nothing you type is uploaded; the text, the password, and the derived key stay on your machine, which is the same privacy property you get from the code above. It is a fast way to confirm your understanding of the IV-prepended, tag-authenticated output format before you wire the same flow into your own app.

Putting it together

The whole pattern is small: a 256-bit key (random or PBKDF2-derived), a fresh random 12-byte IV per message, a 128-bit tag, and the IV (plus salt, if using a password) stored alongside the ciphertext so you can decrypt later. Keep the IV unique, keep the key off the client unless it is user-supplied, and let GCM do the integrity work. With those rules followed, the Web Crypto API gives you production-grade authenticated encryption with no dependencies.

Frequently Asked Questions

GCM is optimized around a 96-bit (12-byte) nonce, which NIST SP 800-38D recommends. A 12-byte IV is used to build the initial counter block directly, avoiding the GHASH-based derivation step that other lengths require, which reduces the chance of subtle implementation mistakes. The IV does not need to be secret, only unique per message under the same key.

No. The IV is not secret, so the common pattern is to prepend the 12 IV bytes to the ciphertext and store or transmit them together. On decryption you slice the first 12 bytes back off as the IV. If you derive the key from a password, store the PBKDF2 salt alongside it the same way.

AES-GCM produces an authentication tag that Web Crypto verifies during decryption. If the ciphertext, tag, or IV is altered, crypto.subtle.decrypt rejects with an OperationError instead of returning corrupted plaintext. You should catch that rejection and treat the data as untrusted. Do not add your own integrity check on top.

Yes, when the key is user-supplied (such as a password run through PBKDF2) or delivered over an already-secure channel. The Web Crypto API is a vetted, native implementation. The danger is hardcoding a key in client-side JavaScript, which anyone can read in DevTools. Never embed a secret key in code shipped to the browser.

OWASP currently recommends at least 600,000 iterations for PBKDF2 with HMAC-SHA-256, since iterations are what make brute-forcing expensive. That minimum rises over time as hardware improves, so check OWASP's latest guidance rather than hardcoding a number permanently, and always use a fresh random salt per password.