AES-GCM Implementation Guide: Authenticated Encryption Done Right
Implement AES-GCM encryption correctly in your web application. Learn nonce management, key handling, and common pitfalls to avoid in authenticated encryption.
AES-GCM (Galois/Counter Mode) combines AES-CTR encryption with GHASH authentication to produce authenticated encryption with associated data (AEAD). A correct implementation uses a 256-bit key, a 96-bit (12-byte) nonce that's unique per key (never reused), a 128-bit authentication tag, and optionally authenticated associated data (AAD) that's verified but not encrypted. NIST SP 800-38D specifies the exact construction. Get any of this wrong, especially nonce reuse, and GCM's security collapses: a single repeated (key, nonce) pair lets attackers recover the authentication key and forge arbitrary ciphertexts. This guide covers the correct way to use AES-GCM in browser, Node, and server contexts.
What GCM Actually Guarantees
Two properties:
Confidentiality: the plaintext cannot be recovered without the key. AES-GCM's CTR-mode encryption layer provides this.
Integrity and authenticity: any modification to ciphertext, nonce, or associated data causes decryption to fail. GHASH produces a 128-bit tag that's verified constant-time on decrypt.
What GCM does not guarantee: non-repudiation (it's symmetric, so anyone with the key can produce valid ciphertexts), replay protection (that's a higher-layer concern), or ordering (for streams, you need to chain somehow).
The key insight: GCM only stays secure when nonces are unique per key. Not mostly unique, not usually unique, actually unique. The security proof falls apart on reuse.
Nonce Management: The Thing That Matters Most
A 96-bit nonce can be generated two ways:
Random: crypto.getRandomValues(new Uint8Array(12)). With random 96-bit nonces under a single key, birthday-bound collisions appear around 2^48 encryptions. NIST suggests a safety margin, so cap uses at 2^32 per key.
Counter: increment a 96-bit integer. Guarantees uniqueness until 2^96 messages. Needs reliable monotonic state, hard in distributed systems.
For file transfer with a fresh key per file, random nonces are perfectly safe — you'll never reach 2^32 encryptions with one key. For chunked encryption under a single file key, use a counter where the nonce encodes the chunk index:
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setUint32(0, messageId);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex));
The disaster case: multiple processes encrypting under the same shared key with random nonces, scaling up to millions of encryptions per second. Birthday collisions become probable. If you must share keys across processes, use a coordinated counter with a process ID prefix.
Don't Use 64-bit Nonces
AES-GCM supports variable nonce lengths, but only 96-bit nonces use the optimized construction specified in NIST 800-38D. Other lengths (typically 64 or 128 bits) trigger a GHASH pre-processing step that reduces performance and increases complexity. Web Crypto API accepts non-96-bit IVs but the spec recommends 96. Just use 96.
Tag Length: Don't Shorten It
GCM's tag is up to 128 bits. Some specs allow truncation to 96, 64, or even 32 bits. Don't. Truncated tags make forgery attacks easier, and the savings (4-12 bytes per message) are irrelevant for file transfer. Web Crypto's AES-GCM defaults to 128-bit tags via the tagLength parameter (default 128). Leave it alone.
Associated Data (AAD)
AAD is data that's authenticated but not encrypted. Use it for metadata you want bound to the ciphertext: filename, content type, expiration timestamp, uploader ID.
await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: nonce,
additionalData: new TextEncoder().encode(JSON.stringify({
filename: "report.pdf",
contentType: "application/pdf",
expires: 1712345678,
})),
},
key,
plaintext
);
If an attacker modifies the AAD, decryption fails. This prevents swap attacks where someone replaces the filename on a stored ciphertext without detection. The receiver must know the exact AAD to decrypt, so store it alongside the ciphertext.
Key Generation and Derivation
For per-file keys:
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
256 bits is the default in 2026. 128-bit AES is still secure but has less post-quantum margin (Grover's algorithm halves effective strength).
For password-derived keys:
const aesKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: crypto.getRandomValues(new Uint8Array(16)),
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
Store the salt alongside the ciphertext. It's not secret; it just must be unique per password.
The Critical Code Path
A minimal encrypt function:
async function encrypt(key, plaintext, aad = new Uint8Array()) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
plaintext
)
);
return { nonce, ciphertext, aad };
}
Decrypt, with proper error handling:
async function decrypt(key, { nonce, ciphertext, aad }) {
try {
return await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce, additionalData: aad },
key,
ciphertext
);
} catch (e) {
// Authentication failure
throw new Error("Decryption failed: ciphertext tampered or wrong key");
}
}
The decrypt call throws OperationError on tag mismatch, short ciphertext, or wrong key. Treat any exception as an integrity failure; don't try to distinguish.
Chunked Large Files
For files over a few hundred megabytes, chunk them to avoid memory pressure:
async function encryptChunks(key, file, chunkSize = 1024 * 1024) {
const chunks = [];
let chunkIndex = 0;
for (let offset = 0; offset < file.size; offset += chunkSize) {
const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer();
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, chunk
);
chunks.push(new Uint8Array(ct));
}
return chunks;
}
Caveat: chunked AES-GCM doesn't detect truncation. An attacker could drop trailing chunks and each surviving chunk decrypts fine. For defense, include the total chunk count in the AAD of each chunk, or use libsodium's crypto_secretstream which handles this.
Server-Side Decrypt (Node.js)
Node's crypto module can decrypt data encrypted in the browser:
const { createDecipheriv } = require('crypto');
function decrypt(key, nonce, ciphertextWithTag) {
const tag = ciphertextWithTag.slice(-16);
const ct = ciphertextWithTag.slice(0, -16);
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
}
Web Crypto appends the 128-bit tag to the ciphertext; Node's API expects tag and ciphertext separately. Split accordingly.
Performance Numbers
On typical 2024-2026 hardware with AES-NI:
- Native (OpenSSL, AES-NI): 3-5 GB/s per core
- Web Crypto (browser with hardware accel): 1-2 GB/s
- libsodium.js WASM AES-GCM: 400-800 MB/s
- Pure JS (@noble/ciphers): 50-150 MB/s
For a 1 GB file, Web Crypto encryption runs in 0.5-1 second. Pure JS takes 7-20 seconds. Pick implementations based on this reality; for large file transfer UX, Web Crypto is the practical choice.
Common Pitfalls Recap
- Nonce reuse: catastrophic. Single biggest failure mode.
- Using
Math.random()instead ofcrypto.getRandomValues(). - Forgetting to authenticate associated metadata with AAD.
- Using CBC mode "because we're used to it." CBC requires separate MAC to match GCM's integrity; an HMAC-CBC construction is correct but complex to get right, GCM avoids the footgun.
- Silently catching decrypt errors and returning garbage. Always fail loud.
- Rolling your own GCM. Use Web Crypto, libsodium, or node:crypto. The GHASH implementation has side-channel pitfalls that took experts years to get right.
HexaTransfer uses Web Crypto's AES-256-GCM with 96-bit random nonces, 128-bit tags, and no AAD because the key is per-file and the filename is stored separately in AEAD-protected metadata. Simple, correct, fast.
When to Pick Something Else
AES-GCM is optimal for file transfer, but consider alternatives in specific cases:
- XChaCha20-Poly1305: 192-bit nonces make random-nonce safety trivial at any scale. Slightly slower on hardware with AES-NI, faster on older ARM without AES-NI. libsodium provides it.
- AES-GCM-SIV: misuse-resistant; nonce reuse doesn't leak the key, just reveals whether plaintexts were equal. Useful when you can't guarantee nonce uniqueness.
For most file transfer workloads in a standard web stack, AES-256-GCM with a fresh key per file and random 96-bit nonces is the right choice and the simplest one to implement correctly.
Try it at hexatransfer.com — free, no account, 10 GB max.
Send large files securely with end-to-end encryption
Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.
Send a file