The one mistake that breaks AES-GCM completely is reusing an Initialization Vector (IV) with the same key.

Consider this scenario: you’re encrypting a message using AES-GCM, a popular authenticated encryption mode. You’ve got your secret key, and you’re ready to go. AES-GCM is great because it provides both confidentiality (nobody can read your message) and integrity (nobody can tamper with it). But there’s a critical, non-negotiable rule: never, ever reuse an IV with the same key.

Let’s see it in action. Imagine we’re using a simplified, hypothetical AES-GCM implementation in Python.

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
import os

# --- Key Generation ---
key = os.urandom(32) # AES-256 key

# --- First Encryption ---
iv1 = os.urandom(12) # 12-byte IV is standard for GCM
plaintext1 = b"This is the first secret message."

# Encrypt
cipher1 = Cipher(algorithms.AES(key), modes.GCM(iv1), backend=default_backend())
encryptor1 = cipher1.encryptor()
ciphertext1 = encryptor1.update(plaintext1) + encryptor1.finalize()
tag1 = encryptor1.tag

print(f"IV 1: {iv1.hex()}")
print(f"Ciphertext 1: {ciphertext1.hex()}")
print(f"Tag 1: {tag1.hex()}")

# --- Second Encryption (THE MISTAKE!) ---
# Reusing iv1 with the same key
iv2 = iv1 # <<< THIS IS THE MISTAKE
plaintext2 = b"This is the second secret message."

# Encrypt
cipher2 = Cipher(algorithms.AES(key), modes.GCM(iv2), backend=default_backend())
encryptor2 = cipher2.encryptor()
ciphertext2 = encryptor2.update(plaintext2) + encryptor2.finalize()
tag2 = encryptor2.tag

print(f"\nIV 2: {iv2.hex()}")
print(f"Ciphertext 2: {ciphertext2.hex()}")
print(f"Tag 2: {tag2.hex()}")

# --- Now, let's try to decrypt and see what happens ---
# Decrypting the first message
try:
    decryptor1 = Cipher(algorithms.AES(key), modes.GCM(iv1, tag1), backend=default_backend()).decryptor()
    decrypted_plaintext1 = decryptor1.update(ciphertext1) + decryptor1.finalize()
    print(f"\nDecrypted Plaintext 1: {decrypted_plaintext1.decode()}")
except Exception as e:
    print(f"\nDecryption Error 1: {e}")

# Decrypting the second message
try:
    decryptor2 = Cipher(algorithms.AES(key), modes.GCM(iv2, tag2), backend=default_backend()).decryptor()
    decrypted_plaintext2 = decryptor2.update(ciphertext2) + decryptor2.finalize()
    print(f"Decrypted Plaintext 2: {decrypted_plaintext2.decode()}")
except Exception as e:
    print(f"Decryption Error 2: {e}")

The problem isn’t immediately apparent when you encrypt the second time. The encryption process itself will complete, and you’ll get a different ciphertext and tag. The disaster strikes during decryption or, more insidiously, when an attacker analyzes the ciphertexts.

Here’s the mental model: AES-GCM combines the AES block cipher in Counter (CTR) mode for encryption with a Galois Message Authentication Code (GMAC) for authentication. The CTR mode is stream-based, meaning it generates a keystream that’s XORed with the plaintext. The GMAC uses a polynomial evaluation function over a finite field, where the "key" for this function is derived from the AES encryption of the IV (or a block derived from it).

When you reuse an IV with the same key, two catastrophic things happen:

  1. Keystream Reuse in CTR Mode: The core of the CTR mode is generating a unique keystream for each block. This keystream is produced by encrypting a counter value (which is derived from the IV and block index). If the IV is reused, the initial counter values are the same, meaning the exact same keystream will be generated for the beginning of both messages. If an attacker has both ciphertexts, they can XOR them together: ciphertext1 XOR ciphertext2. Since ciphertext = plaintext XOR keystream, this operation results in (plaintext1 XOR keystream) XOR (plaintext2 XOR keystream). The keystream terms cancel out (because X XOR X = 0), leaving you with plaintext1 XOR plaintext2. This allows the attacker to recover the XOR difference of the plaintexts. While not directly the plaintexts, this is a massive leak of information. If one plaintext is known or guessed (e.g., common headers, known strings), the other can be easily recovered.

  2. Authentication Key Leakage: The GMAC authentication tag is computed using the AES key and the IV. Specifically, the GMAC calculation involves a polynomial evaluation. The "key" for this polynomial evaluation is derived from the AES encryption of the first block of data, which in GCM is itself derived from the IV. When an IV is reused, the same authentication subkey is used for both messages. The GMAC algorithm is designed such that if an attacker can obtain two messages encrypted with the same key and IV, and their corresponding tags, they can potentially recover the authentication subkey (H, which is E_k(0^128)). Once the attacker has H, they can forge messages for any IV and key combination, effectively breaking the integrity and authenticity guarantees completely. They can then potentially decrypt messages as well by manipulating the ciphertext and re-calculating the tag.

The most surprising consequence is that both confidentiality and integrity are broken, not just one. It’s not just that decryption might fail; it’s that an attacker can potentially recover plaintexts and forge valid ciphertexts.

The only way to fix this is to ensure that for a given key, every single IV is unique. The IV doesn’t need to be secret, but it must be unique. For GCM, a 12-byte (96-bit) IV is standard and recommended. This gives you 2^96 possible IVs, which is an astronomically large number. If you generate IVs randomly using os.urandom(12), the probability of collision is vanishingly small, assuming you don’t generate trillions upon trillions of IVs.

The next problem you’ll hit is if your IV generation mechanism is flawed, leading to IV collisions or predictable IVs, even if you’re not strictly "reusing" them in the sense of identical byte sequences.

Want structured learning?

Take the full Cryptography course →