A nonce reuse attack is devastating because a single reused nonce allows an attacker to recover the original plaintext from two encrypted messages, even if the encryption keys are unknown.
Let’s see this in action. Imagine we have a simple XOR encryption scheme. We want to encrypt two messages, M1 and M2, using the same secret key K and a unique, randomly generated value called a nonce.
C1 = M1 XOR K XOR Nonce
C2 = M2 XOR K XOR Nonce
Here, C1 and C2 are our ciphertexts. Now, what happens if, by mistake, we reuse the Nonce?
C1 = M1 XOR K XOR Nonce_reused
C2 = M2 XOR K XOR Nonce_reused
The attacker intercepts C1 and C2. They don’t know K or Nonce_reused. But, they can perform a simple operation:
C1 XOR C2 = (M1 XOR K XOR Nonce_reused) XOR (M2 XOR K XOR Nonce_reused)
Because XOR is associative and commutative, and X XOR X = 0, the K and Nonce_reused terms cancel out:
C1 XOR C2 = M1 XOR M2
The attacker now has M1 XOR M2. This is still not the original plaintext, but it’s a massive step. If the attacker knows or can guess parts of M1 or M2 (which is common for messages like "YES" or "NO", or headers), they can deduce the rest. For example, if M1 is "ATTACK AT DAWN" and M2 is "ATTACK AT DUSK", then M1 XOR M2 will reveal the common prefix "ATTACK AT " and the differing parts "DAWN" XOR "DUSK". With enough known plaintext, the entire original messages can be recovered.
The core problem is that the nonce’s purpose is to ensure that even if the same plaintext is encrypted multiple times with the same key, the resulting ciphertexts are different. When a nonce is reused, this guarantee is broken. The K XOR Nonce part of the encryption becomes a single, repeated value. The encryption then effectively becomes Ciphertext = Plaintext XOR (K XOR Nonce). When two such ciphertexts are XORed together, the (K XOR Nonce) term, being identical, cancels out, leaving Plaintext1 XOR Plaintext2.
This is why nonces must be unique for every encryption operation under a given key. They don’t need to be secret, but they absolutely must not be repeated.
The most common scenario where this happens is in stream ciphers or authenticated encryption modes that rely on a nonce. For instance, consider the ChaCha20-Poly1305 cipher. It uses a 96-bit nonce. If you send two messages, Msg1 and Msg2, encrypted with the same ChaCha20 key and the same 96-bit nonce, an attacker can recover Msg1 XOR Msg2. If the messages are predictable (e.g., small, contain known headers, or are part of a protocol with fixed message formats), this is catastrophic.
To prevent this, systems must ensure nonce uniqueness. For many protocols, this is achieved by using a counter for the nonce. Each time a message is encrypted, the counter is incremented, guaranteeing a unique nonce. For example, if you’re using a counter-based nonce, your nonces might look like 0000000000000001, 0000000000000002, 0000000000000003, and so on. If the counter wraps around (which is highly unlikely with a 96-bit nonce, as it allows for over 2^32 billion billion unique values per key), or if a random nonce generator is used, careful seed management and collision detection are critical.
A common implementation mistake is using a timestamp as a nonce without considering that multiple events can occur within the same timestamp unit, leading to reuse. Another is deriving a nonce from a counter that is not properly synchronized across all encrypting instances using the same key.
The fix is always to ensure that the combination of (Key, Nonce) is unique for every single encryption operation. If you’re using a library, check its documentation for how it handles nonces. Does it require you to provide one? Does it generate one? Is it a counter or random? If you’re implementing it yourself, implement a robust counter mechanism or a cryptographically secure random number generator with a very large output size for your nonces.
The next problem you’ll encounter is understanding how to manage keys securely, as a compromised key invalidates all nonce protection.