Timing Side Channels in Go: Why == on a MAC Verification Is a Security Bug
Here is a webhook handler that looks entirely reasonable:
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.
What == actually does to a byte slice
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.
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. 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"
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. 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
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. If you are comparing something that is not strictly a MAC but has 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, 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 and 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
It is worth glancing at what "constant time" buys you here, because the term overpromises slightly. ConstantTimeCompare checks 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, ORs all the results together, and 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 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, which 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, and session token comparisons in anything that is not already going through a framework's cookie handling.
It is worth being precise about when this matters, because not every equality check on sensitive-looking data is a vulnerability. 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.
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, which is really just another way of saying: 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 ==.