Comparing MACs with == Is a Security Bug in Go
Here is a webhook handler that looks entirely reasonable, and is broken:
func verifySignature(payload, signature []byte, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(payload)
expected := mac.Sum(nil)
return string(expected) == string(signature)
}
It compiles, it passes every functional test you throw at it, and it correctly rejects a forged signature. It is also broken, and the reason has nothing to do with the HMAC computation itself. The bug is entirely in that final comparison.
== stops at the first wrong byte, and that leaks time
When Go compares two strings or byte slices for equality, it does not compare all the bytes and then decide. It walks the bytes and stops the moment it finds one that differs.
This is the sensible thing to do if you are comparing, say, two file paths, because it is fast and nobody cares how long the comparison took. But for a secret comparison, how long the comparison took is exactly the thing you have just leaked.
How an attacker turns that into a byte-by-byte search
If an attacker sends a signature that gets the first byte right and every other byte wrong, the comparison spends a fraction longer discovering the mismatch than it would if the first byte were also wrong. Multiply that by a slow enough hash function or a big enough sample size and you have a signal. The attack then goes:
- guess a byte;
- measure the average response time across many requests;
- keep the byte that took longest;
- move to the next byte.
Instead of needing to brute-force the entire MAC space, an attacker can recover it 256 guesses at a time, one byte at a position, which turns an infeasible search into a linear one.
This is not a theoretical worry invented for blog posts. Nate Lawson found exactly this flaw in Google's Keyczar library over a decade ago: the HMAC verification used a naive byte comparison, and the timing difference was measurable enough to matter.
It is the kind of bug that survives code review indefinitely, because the code is doing the cryptographically correct thing right up until the very last line.
"But the network is noisy" doesn't save you
The obvious objection is that jitter on a real network swamps a nanosecond-scale timing difference, so this is only a concern if the attacker is on the same machine or the same local network. There is something to that: the more hops and the more contended CPUs between attacker and server, the more samples are needed to pull a signal out of the noise.
But Crosby and Wallach's 2009 paper on remote timing attacks demonstrated that with enough repeated measurements and some statistics, timing differences on the order of tens of microseconds are recoverable even over a wide-area network, not just on localhost.
And plenty of realistic deployments remove the noise problem entirely:
- services on the same rack or the same cloud availability zone;
- requests over a Unix domain socket to a sibling container;
- an attacker who has landed on the same host as a multi-tenant service.
"The network will hide it" is not a security control, it is a hope, and it degrades exactly when the attacker is closest to you, which is also when they are most dangerous.
The fix is one line: hmac.Equal
Go's standard library already has the tool for this, and it is a one-line change:
func verifySignature(payload, signature []byte, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(payload)
expected := mac.Sum(nil)
return hmac.Equal(expected, signature)
}
hmac.Equal is a thin wrapper around crypto/subtle.ConstantTimeCompare, and it exists precisely so nobody has to remember to import crypto/subtle themselves when checking a MAC.
Some checks aren't strictly a MAC but have the same "attacker-supplied value versus secret-derived value" shape, such as a webhook signature header, an API key check, or a hand-rolled CSRF token comparison. For those, reach for subtle.ConstantTimeCompare directly:
import "crypto/subtle"
func verifyToken(expected, provided []byte) bool {
return subtle.ConstantTimeCompare(expected, provided) == 1
}
Note the return value is an int, not a bool: 1 for equal, 0 for not equal. It is a very old-school API. It is easy to write if subtle.ConstantTimeCompare(a, b) and have the compiler correctly tell you that is nonsense, which is at least a friendly failure mode.
How ConstantTimeCompare actually avoids the leak
Quick detour into the internals, because the term "constant time" overpromises slightly and it's worth seeing what it actually buys you.
ConstantTimeComparechecks the lengths first and returns immediately if they differ. That is fine: the length of a fixed-size MAC is public information, not a secret, so branching on it leaks nothing useful.- Once lengths match, it XORs each corresponding byte pair and ORs all the results together.
- It only branches once, at the very end, on the accumulated result.
There is no early exit partway through the data, so the number of matching leading bytes has no effect on how long the function takes.
"Constant" means data-independent, not a fixed number of nanoseconds
"Constant time" in this context means data-independent time, not some fixed number of nanoseconds guaranteed by the language spec. Modern CPUs have branch predictors, cache hierarchies and variable-latency instructions that can reintroduce timing variation even in code with no explicit branches.
That is why constant-time cryptographic primitives are typically hand-verified against a specific instruction sequence rather than just "written to look branchless" in a high-level language.
crypto/subtle's implementation is deliberately conservative about this: it operates a byte at a time with simple bitwise operations rather than anything that could tempt the compiler into optimising in a data-dependent way.
Where this bug actually hides
The pattern to watch for is any place a secret-derived value is compared against something an attacker controls or can influence:
- webhook signature verification (Stripe, GitHub, and similar services all rely on the receiving code doing this correctly);
- bearer token or API key checks against a stored value;
- JWT signature verification, if you are ever unwise enough to hand-roll it instead of using a maintained library;
- session token comparisons in anything that is not already going through a framework's cookie handling.
Not every equality check on sensitive data is a vulnerability
It is worth being precise about when this matters. If both sides of the comparison are already known to the attacker, or neither side is secret, timing tells them nothing they didn't have.
The risk is specifically when one side is a secret the attacker is trying to guess and the other is a value they can vary and resubmit.
Password comparisons usually dodge this problem for a different reason: you should never be comparing a plaintext password directly at all, and libraries like bcrypt or Argon2 already build the comparison correctly into their verify functions.
Your tests will never catch it
The annoying thing about this class of bug is that it is invisible in every functional test. Your test suite checks that valid signatures pass and invalid ones fail, and == does both of those things correctly.
The only way to notice is to know to look for it. Any time you write a comparison whose sole purpose is "does this secret match what the caller sent", stop and reach for hmac.Equal or subtle.ConstantTimeCompare before you reach for ==.