AES-GCM in Go: the 2^32 message limit is a comment, not a check

Table of contents (8 sections)

I'm reading Serious Cryptography (Jean-Philippe Aumasson, No Starch Press, 2nd edition, 2025) to write a reading note. The chapter on AES-GCM, the authenticated encryption most used according to the book (p. 164), stops me at one sentence.

AES-GCM encrypts a message with a secret key and attaches a tag, a seal that proves nobody modified it. For each message, you also give it a nonce: a number that must never be used twice with the same key. The book's sentence: "AES-GCM's biggest weakness is its fragility in the face of nonce repetition" (p. 166). In other words, its worst weakness is its fragility when a nonce repeats.

I write Go. In the documentation of crypto/cipher, I read that a key must not encrypt more than 2^32 messages. That reads "2 to the power of 32": 32 twos multiplied together, that is, a little over four billion. I call this limit the budget of the key. If nonces are drawn at random, one of them ends up repeating: the budget is there to keep that risk tiny. I look for where Go checks it. In the code I read, nowhere. So I wrote the guardrail: a 93-line file, tested.

This article answers three questions. Why is a repeated nonce so serious? Why is the budget 2^32, when a randomly drawn nonce only really risks repeating around 2^48 messages? And what does Go check?

$ git clone https://github.com/ohugonnot/gcm-nonce-budget
$ cd gcm-nonce-budget && go test -race ./...

ohugonnot/gcm-nonce-budget

A small piece of Go code that encrypts with AES-GCM using a random nonce. It refuses to encrypt when the key has already been used for 2^32 messages. Each line below says what is proven, and by what.

  • VerifiedReusing a nonce reveals how two messages differ: TestNonceReuseLeaksXorOfPlaintexts, on the real AES-GCM of the standard library.
  • VerifiedThe guardrail refuses to encrypt past the budget, even when 16 encryptions happen at the same time: TestBudgetRefusesAfterLimit, TestBudgetHoldsUnderConcurrency.
  • VerifiedThe tests fail when the code is deliberately broken in three ways: ./mutations.sh (trial run on November 3, 2026).
  • Not verifiedThe real 2^32-message budget: the test sets a budget of 3 messages, it does not encrypt four billion messages.
  • Not verifiedThe running total of messages is kept in memory. A restart resets it to zero, and two programs that share a key do not count each other.

A repeated nonce gives away your messages

From the key and the nonce, AES-GCM builds a long string of bits: the keystream, written K. It combines K and the message with an XOR, written ⊕. Bit by bit, the result is 1 if the two bits differ, and 0 if they are equal. So the ciphertext equals message ⊕ K.

The XOR has two useful properties. Combining a string with itself gives only zeros: K ⊕ K = 0. Combining a string with zeros does not change it. Yet the key and the nonce alone decide the keystream K. If the nonce repeats, two different messages get the same keystream K.

Let P1 and P2 be the two plaintexts, C1 and C2 their ciphertexts. An attacker who intercepts C1 and C2 computes C1 ⊕ C2, that is, (P1 ⊕ K) ⊕ (P2 ⊕ K). The two keystreams cancel out. What is left is P1 ⊕ P2: how the two messages differ. Figure 1 draws it.

I checked it on Go's real AES-GCM. The test encrypts two transfers with the same key and the same nonce. The first sends 100 euros to Alice. The second sends 900 euros to Mallory, the usual name for the attacker in cryptography. In the code, aead is Go's AES-GCM object and Seal encrypts. The nonce is 1 for both messages. The loop then compares C1 ⊕ C2 and P1 ⊕ P2 byte by byte (^ is the XOR).

	nonce := bytes.Repeat([]byte{1}, aead.NonceSize())
	p1 := []byte("virement de 100 euros a Alice...")
	p2 := []byte("virement de 900 euros a Mallory.")
	c1 := aead.Seal(nil, nonce, p1, nil)
	c2 := aead.Seal(nil, nonce, p2, nil)
	for i := range p1 {
		if c1[i]^c2[i] != p1[i]^p2[i] {
			t.Fatalf("octet %d : C1^C2 != P1^P2", i)
		}
	}

Full file: noncebudget/budget_test.go, lines 85 to 110

The test passes. Both messages start with the same twelve characters, "transfer of" and the space that follows. These equal bytes give zero in P1 ⊕ P2, so the attacker sees where the messages differ. If they guess one of the two, they read the other.

The damage does not stop there. The book adds that with a repeated nonce, the attacker can also forge fake tags, valid for any message (p. 166). My repo does not demonstrate this second leak. In 2016, researchers looked for encrypted web servers (over HTTPS) that repeated their nonces. They found 184, including 23 that still used the all-zero nonce (p. 166).

Why the budget is 2^32 and not 2^48

The simplest way is to draw each nonce at random. A 96-bit nonce is a string of 96 digits, each 0 or 1. That makes 2^96 possible nonces, a gigantic number. You might think a duplicate never happens. It happens much sooner.

This is the birthday paradox. In a group of 23 people, two share a birthday with a chance of about one in two (p. 120). Yet there are 365 possible days. The trap: you don't compare one person to a date, but all the people to each other, two by two. The number of pairs grows like the square of the number of people.

Same for nonces. With n-bit nonces, a duplicate becomes likely after about 2^(n/2) draws, the square root of the number of possible values. My test checks it on a 24-bit nonce. It has 16,777,216 values, yet the first duplicate arrives after about 5,100 draws. For 96 bits, the rule gives 2^48, that is close to 281 trillion messages.

My small demo program computes the risk of at least one duplicate, for 64-bit and 96-bit nonces. Each row of the table gives a number of messages. A risk of 1 means the duplicate is certain, and 0.393 means 39%. The number 1.16e-10 reads 0.000000000116.

messages   nonce 64 bits   nonce 96 bits
2^16       1.16e-10        2.71e-20
2^24       7.63e-06        1.78e-15
2^32       0.393           1.16e-10
2^40       1               7.63e-06
2^48       1               0.393

Full code: cmd/demo/main.go, run with go run ./cmd/demo

Read the 2^48 row: with 96 bits, the duplicate risk reaches 39%. With 64 bits, it reaches 39% already at 2^32 messages. The book judges this number of messages unacceptably low (p. 82).

What the NIST standard requires

A single repetition is enough to expose everything, so the risk must stay tiny. The NIST SP 800-38D standard, published by the American standards institute, requires that it stay under one chance in 4.3 billion (section 8). For randomly drawn nonces, it derives a simple rule: at most 2^32 messages per key (section 8.3).

Let's redo the calculation with 96 bits. A number n of messages makes about n²/2 pairs, and each pair has a 1 in 2^96 chance of sharing the same nonce. The risk is therefore about n²/2^97. For n = 2^32, that gives 2^64/2^97 = 2^-33, that is one chance in 8.6 billion: this is the 2^32 row of the table, 1.16e-10. For n = 2^33, that gives 2^66/2^97 = 2^-31, that is one chance in 2.15 billion, too high. So 2^32 is the largest power of two the standard accepts. That is the budget.

Between the budget (2^32) and the 39% point (2^48), there is 2^16, that is 65,536 times more messages. By the same calculation, a 64-bit nonce would leave a tiny budget: 2^16 messages, about 65,000. The standard also closes that door: a randomly drawn nonce must have at least 96 bits of randomness (section 8.2.2).

What Go writes, and what it checks

Since Go 1.24, NewGCMWithRandomNonce draws the nonce for you and sticks it in front of the ciphertext. Its documentation says: "A given key MUST NOT be used to encrypt more than 2^32 messages". That means a key must not exceed the budget. The same warning appears in the NewGCM example: "Never use more than 2^32 random nonces with a given key".

I read Go 1.26.10's code, from NewGCMWithRandomNonce down to the internal function SealWithRandomNonce. Their only checks are panics, that is, program stops with an error, on invalid arguments. None of these functions counts messages: Go states the budget and does not check it.

There is another way to choose the nonce: a counter, which goes 0, then 1, then 2. It never repeats as long as a single program increments it. Go uses one for TLS 1.3, the protocol that encrypts web connections, when the connection uses AES-GCM. But it only checks that the counter moves forward in a mode reserved for certified environments, called FIPS 140-3.

The guardrail: a running total that never exceeds the budget

My code stores the key in a structure that keeps the running total of messages already encrypted. For each encryption, it reserves a slot in the budget, then draws the nonce. When the budget is used up, it refuses. In the excerpt, s.used is that running total and s.budget the budget.

	for {
		u := s.used.Load()
		if u >= s.budget {
			return nil, ErrBudgetExhausted
		}
		if s.used.CompareAndSwap(u, u+1) {
			break
		}
	}

Full file: noncebudget/budget.go, lines 58 to 75

The loop reads the running total. If the budget is reached, it refuses. Otherwise, it tries to change the running total from u to u+1. This operation, called compare-and-swap (CompareAndSwap in Go), succeeds only if no other task has changed it in the meantime. A task is a piece of work that runs at the same time as the others. Without this operation, two tasks could read the same value, reserve the same slot and exceed the budget. The test launches 16 parallel tasks aiming for 1,000 messages: exactly 1,000 pass, never one more.

A passing test does not prove it protects anything. So the script mutations.sh breaks the code in three ways, one at a time. Each deliberate break is called a mutation. The script checks that the tests fail.

attrapée     : le budget n'est plus vérifié
attrapée     : le nonce n'est plus tiré au hasard
attrapée     : un refus consomme quand même le budget

The script prints in French: attrapée means caught.

An automatic reviewer, an AI model that had not written the code, reviewed the repo. It found a flaw in this script. One of my mutations did not compile: the compiler, the program that turns code into an executable, rejected it. The script counted it as "caught". Yet no test had run, so nothing proved the tests could detect that break. The script now refuses a mutation that does not compile.

What the repo does not prove is still open. The running total lives in memory: a service that restarts starts again from zero, and so gets another 2^32 messages on the same key. For a real service, rotate the key regularly, well before the budget: that is key rotation. Encrypt only with the current key and keep the old ones to decrypt. The repo does not handle this rotation.

My take: a counter when you can

My take: if you control the program that sends messages, the sender, use a counter. It never repeats as long as a single sender holds it. For a 96-bit nonce built this way, the standard sets no budget of 2^32 messages. Only the size of the counter bounds the number of messages (section 8.3).

The random nonce is justified for a stateless service. Such a service forgets everything between runs, so it cannot keep a counter without shared storage. Then the budget and key rotation become mandatory. My in-memory running total is not enough: it must be stored, or the key must change at each startup. In both cases, a limit that lives only in a code comment, text the program ignores, is not a limit.

Conclusion

I looked for where Go checks the 2^32 budget, and the answer fit in one search through the code. The documentation speaks to humans with a "MUST NOT" the program never reads. Between the point where a repeat becomes likely and the NIST budget, there is a 65,536-fold margin. That margin exists so that a single mistake stays unlikely, and it is up to your code to enforce it.

The books behind this article

  • Serious Cryptography, 2nd Edition Jean-Philippe Aumasson, No Starch Press 2025

    Used: AES-GCM's fragility against a repeated nonce (p. 166), the birthday paradox (p. 120), the 64-bit nonce (p. 82). To correct: the book does not give the 2^32-message budget per key in GCM. Three phrasings searched across the 380 pages turn up nothing. I took it from the NIST standard.

Sources

Documentation consulted on November 3, 2026:

In the Developer's Great Book

A free book where each idea is told once, in its place, from silicon to human judgment.

Comments (0)