AES-GCM is the default authenticated encryption choice in most stacks, and it has one sharp edge: the nonce (also called the IV). We chose this as an evergreen topic because no existing article here covers cryptographic nonce misuse, and no fresh news item from the last 48 hours met our bar for developer relevance. Search results for recent disclosures were mostly aggregator pages, which we did not treat as sources.
What the documentation says
The Python cryptography AEAD documentation (opened) is blunt: “NEVER REUSE A NONCE with a key”, and reuse “compromises the security of any message with that nonce and key pair.” It also notes NIST recommends a 96-bit IV length.
The Go crypto/cipher documentation (opened) states the nonce “must be NonceSize() bytes long and unique for all time, for a given key.” It adds that you should never use more than 2^32 random nonces with a given key because of the risk of a repeat.
Neither page spells out the full attack. What follows in the next section is our own explanation, backed by a local test.
Why reuse is so damaging
GCM encrypts with a counter-mode keystream derived from the key and nonce. If two messages share both, they share the keystream, so XORing the two ciphertexts yields the XOR of the two plaintexts. An attacker who knows or guesses one plaintext (a fixed header, a JSON skeleton) recovers the other. We state the further consequence from general knowledge, not from a source we opened: repeated nonces also expose the authentication subkey, which lets an attacker forge tags.
This test ran locally on Node.js v22:
import crypto from 'node:crypto';
const key = crypto.randomBytes(32);
const nonce = crypto.randomBytes(12); // reused on purpose: this is the bug
function enc(pt) {
const c = crypto.createCipheriv('aes-256-gcm', key, nonce);
return Buffer.concat([c.update(pt), c.final()]);
}
const p1 = Buffer.from('transfer $100 to alice');
const p2 = Buffer.from('transfer $999 to mallo');
const c1 = enc(p1), c2 = enc(p2);
const xorCt = Buffer.from(c1.map((v, i) => v ^ c2[i]));
const xorPt = Buffer.from(p1.map((v, i) => v ^ p2[i]));
console.log(xorCt.equals(xorPt)); // true: ciphertexts leak plaintext relationships
The output was true.
How it happens in real code
Common patterns we would flag in review (inference from general practice, not from a specific incident):
- A constant nonce, such as a zero-filled buffer or a string literal, “to make output deterministic.”
- A counter reset on restart, or two service instances each counting from zero under a shared key.
- A nonce derived from a timestamp, which collides under concurrency.
- Random nonces under one key for too many messages, past the 2^32 guidance above.
Safe patterns
Generate a fresh random 96-bit nonce per message, store it with the ciphertext, and bind context with associated data (AAD) so ciphertexts cannot be swapped between records. This JavaScript helper was run locally: it round-trips and rejects a wrong AAD.
function seal(pt, aad) {
const nonce = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 });
c.setAAD(aad);
const ct = Buffer.concat([c.update(pt), c.final()]);
return Buffer.concat([nonce, ct, c.getAuthTag()]);
}
function open(blob, aad) {
const nonce = blob.subarray(0, 12);
const tag = blob.subarray(blob.length - 16);
const ct = blob.subarray(12, blob.length - 16);
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 });
d.setAAD(aad);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]); // throws on tampering
}
Python, untested (no Python runtime was available here; the API shape follows the documentation we opened):
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
aead = AESGCM(key)
def seal(plaintext: bytes, aad: bytes) -> bytes:
nonce = os.urandom(12)
return nonce + aead.encrypt(nonce, plaintext, aad)
def open_(blob: bytes, aad: bytes) -> bytes:
return aead.decrypt(blob[:12], blob[12:], aad) # raises InvalidTag
Go, untested (no Go toolchain was available here). Per the Go documentation, NewGCMWithRandomNonce generates a random 96-bit nonce, prepends it to the ciphertext, and has a NonceSize of zero. Availability depends on your Go version, which we did not verify; check yours before relying on it:
block, _ := aes.NewCipher(key) // handle the error in real code
aead, _ := cipher.NewGCMWithRandomNonce(block)
sealed := aead.Seal(nil, nil, plaintext, aad)
plain, err := aead.Open(nil, nil, sealed, aad)
Checklist
- Never hard-code or reset a nonce; prefer library helpers that generate it for you.
- Keep random-nonce volume well under 2^32 messages per key, and rotate keys before then.
- Use AAD to bind ciphertext to its context (user ID, record ID).
- Treat decryption failure as a hard error and never fall back to unauthenticated output.
- In review, grep for
createCipheriv,AESGCM(, andNewGCMand inspect where the nonce comes from.
Sources
- Python cryptography: AEAD (opened)
- Go crypto/cipher (opened)
- NIST SP 800-38D: we tried to open it but the PDF was not readable by our fetcher, so we cite no figures from it.