Why constant-time comparison is a thing
An ordinary equality check leaks the secret it's supposed to protect — one byte at a time, through the clock. Constant-time comparison exists because == is faster than it should be.
On this page
The picture version
Four pictures for a reader who has wondered why == isn’t good enough. The prose below fills in the seams the pictures skip.
1 · The problem
The clock answered a question nobody asked it.
2 · The single line
Because == stops at the first byte that differs.
3 · The fix
Touch every byte, and record the answer with arithmetic instead of a branch.
4 · Keep this card
The whole thing on one index card.
Why it exists
You have probably met a login form that rejects a made-up username the instant you hit enter, but takes a visible beat longer when the username is real and only the password is wrong. Nobody told you which accounts exist. The delay did — the server did more work in one case than the other, and “how long did that take” turned out to be a channel nobody meant to open.
Now shrink that idea down to a single line of code. The first time you read
security code and see hmac.compare_digest(a, b) instead of plain a == b,
it looks paranoid. The two strings are right there. Why not just compare
them?
Because == short-circuits.
The usual way to compare two byte strings — and what mainstream languages
generally do for == on strings and byte arrays — is to walk them
left-to-right and bail out the moment a byte mismatches. That’s the
obviously correct way to write the function — why keep checking after you
already know the answer? — and it’s a disaster the moment one of those
strings is a secret.
Imagine a server that authenticates webhook calls by checking an HMAC in a request header against the expected value. An attacker who controls the header can submit guesses and time how long the comparison takes. A guess whose first byte matches the real tag takes a tiny bit longer to reject than one whose first byte is wrong, because the loop ran one more iteration. The attacker fixes byte 0, sweeps byte 1, and so on. They have not stolen the secret key; they have read the comparison’s reply byte by byte through the clock.
That whole class of bug is called a timing side channel,
and it’s the reason compare_digest, crypto.timingSafeEqual, and
subtle.ConstantTimeCompare exist in your standard library.
Why it matters now
Two reasons it keeps mattering.
First, the network is no longer the noise barrier it used to be. The classic objection — “you can’t actually measure nanosecond differences over the internet” — was always an overstatement, and modern infrastructure makes it worse. Your service and your attacker are frequently a few milliseconds apart — same cloud region, sometimes the same datacentre — rather than continents apart. Brumley and Boneh’s Remote Timing Attacks Are Practical (USENIX Security, 2003) demonstrated key recovery against an OpenSSL-based server across a local network, and the network distance between attacker and target has only shrunk since. Given enough samples, an attacker can average away jitter and resolve timing differences far smaller than the noise in any single request.
Second, modern services are covered in shared-secret comparisons:
webhook signatures (Stripe, GitHub, Slack), API tokens, password reset
tokens, session IDs,
CSRF
tokens, license keys, and the tag check in an HMAC-signed JWT. Each
one is a place where == against attacker-controlled input is a slow,
quiet leak.
The fix is mechanical and cheap. The cost of not applying it is that you leave a channel open on every secret you compare with the wrong operator — whether anyone can practically walk through it depends on how much signal they can gather, which is not a thing you get to decide for them.
The short answer
constant-time compare = always touch every byte + combine results without branching
Picture to keep: a bouncer who reads every name on the guest list all the way to the bottom before saying yes or no — even when the very first name already settled it — so nobody outside can time the door and work out how far down the list their guess got. (Where the analogy breaks: a bouncer only has to control his own behaviour. Your code also has to survive a compiler that would happily “help” by restoring the early exit, and a CPU whose caches make some reads faster than others.)
A constant-time equality function takes the same amount of time to run no matter where (or whether) the inputs differ. It walks both buffers to the end every time and folds the per-byte differences together with bitwise operations, so the CPU never takes a data-dependent branch. The function still returns “equal” or “not equal” — it just refuses to leak how it got there.
How it works
Build it by breaking it. Back to the webhook server, checking the attacker’s guessed tag against the real HMAC.
Naive attempt: if (a[i] != b[i]) return 0; inside the loop. Why it
breaks: the number of iterations is now a readable measurement of how many
leading bytes matched. That’s the leak.
Fix 1 — never return early. Run the loop to n no matter what. But
now what? You still have to record, at each position, whether the bytes
matched, and an if inside the loop puts the branch straight back.
Fix 2 — record it arithmetically instead of with a branch. a[i] ^ b[i]
is zero exactly when the bytes are equal, and OR-ing those XORs into an
accumulator keeps any non-zero bit forever. No comparison, no jump, same
work every iteration:
int ct_equal(const uint8_t *a, const uint8_t *b, size_t n) {
uint8_t diff = 0;
for (size_t i = 0; i < n; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
But there’s still a branch — the final diff == 0. That one is fine, and
the reason is worth holding onto: it branches on a value that depends on
the whole comparison, not on any particular position. An attacker who
times the function learns only the bit they were always going to be told
anyway — equal or not equal. Constant-time doesn’t mean “no branches ever”;
it means no branch, no memory access, and no instruction whose duration
depends on a secret.
But the lengths can still betray you. Give this function two buffers of
the same length. What the real libraries do differs, and the difference is
instructive: Go’s crypto/subtle.ConstantTimeCompare returns immediately on a
length mismatch, Node’s crypto.timingSafeEqual throws unless the lengths
match, and Python’s hmac.compare_digest documents that it can still leak the
types and lengths of its arguments. None of them is buggy; they are all
assuming the lengths are public. The rule that generalises is narrower than
“never leak length”: don’t leak the length of something whose length is
secret. For a fixed-size HMAC tag it isn’t.
Why the compiler keeps trying to ruin this
The genuinely hard part of constant-time code is not writing the loop. It’s
keeping the optimizer from helpfully un-writing it. A sufficiently smart
compiler can notice that once diff is non-zero it can never become zero
again, and “optimize” the loop into an early exit. It can also lower
diff |= ... into branchy code on some architectures.
This is why production constant-time primitives live in the standard
library or in audited libraries (libsodium, BoringSSL, Go’s
crypto/subtle), often with compiler barriers, volatile reads, or
hand-written assembly. Rolling your own in a high-level language is
usually fine for the algorithmic shape but offers no real guarantee that
the binary the compiler emits is still constant-time. Use the stdlib
function.
Show the seams
- “Constant-time” is aspirational at the hardware level. Modern CPUs have data caches, branch predictors, variable-latency instructions (integer divide, some multiplies on some chips), and SMT siblings that observe each other. A function can be branch-free at the source level and still leak through cache timing if it indexes a table with secret data. Getting there for things like AES is part of what drove hardware support: Intel’s own materials describe AES-NI as lowering the risk of the timing and cache attacks that table-based software AES is exposed to.
- It’s not just
==. Anything that branches on a secret leaks. String comparisons in databases, regex matches against secret-shaped fields, earlyreturnin a signature verifier — all the same family. The question to ask code-review-style is: does the time to run this depend on bytes the attacker isn’t supposed to know? If yes, you have a channel. - Length leaks are real. Some libraries deliberately compare a hashed
version of both sides at fixed length to dodge this. Python’s
hmac.compare_digestis explicit that its guarantee covers the contents: it documents that the operation can still leak information about the types and lengths of the values being compared. Read the docs of whatever you call — the promise is narrower than “this function is safe.” - The attack budget is non-trivial but not impossible. Recovering a 16-byte tag over the open internet, against a target with a lot of jitter, is harder than the textbook explanation makes it sound. It often takes millions of requests and statistical denoising. The defense is still cheap, so the cost-benefit is one-sided: you should always pay the trivial cost to remove the channel rather than argue about whether someone can afford to exploit it. There is no published headline figure for “fewest requests to recover an HMAC tag against a typical cloud service” — the answer depends so heavily on the target and the network path that the serious work lives in academic side-channel papers measuring specific setups, not in a number you can quote.
The mental model: when one of the inputs is a secret, the comparison function is part of your cryptography, not part of your control flow. Treat it accordingly.
You started with constant-time compare = always touch every byte + combine results without branching. What did this post add? — + and nothing else downstream may vary with the secret either: not the length check, not the
compiler’s rewrite of your loop, not a table lookup indexed by a secret
byte. The loop is the easy half; keeping the rest of the pipeline
uniform is why this lives in the standard library instead of in your file.
Check yourself
Before you go — you replace == with a proper constant-time compare in
your webhook handler, but you keep the early if (len(given) != len(expected)) return False in front of it. What can an attacker still learn, and does it
matter?
Answer
They learn the length of the expected tag, because wrong-length guesses are rejected measurably faster than right-length ones. For a fixed-size HMAC tag that’s near-worthless — the length was public anyway, implied by the algorithm. For a variable-length secret like an API key or a password reset token, it’s a real leak: it collapses the search space before the attacker starts guessing content. For the webhook case specifically the clean answer is that the tag width is fixed by the algorithm, so reject anything that isn’t exactly that length and you’ve leaked nothing new. Where the secret’s length is genuinely variable, the usual move is to compare something fixed-width instead — a hash of each side — so length stops being an observable at all.
And one more — a signature verifier is fully constant-time in its comparison, but decrypts using an AES implementation that indexes a 256-entry lookup table with key-derived bytes. Is the system constant-time?
Answer
No. The instruction stream may be identical every run, but the addresses it touches are not: which table entries land in cache depends on secret bytes, and cache hits and misses have very different latencies. An attacker who can influence or observe the cache can recover key material without the code ever branching on a secret. This is precisely the failure mode AES-NI was introduced to remove — do the rounds in hardware and there is no secret-indexed table to probe. “No branches” is necessary, not sufficient; the real rule is that no observable — time, cache state, power — may depend on the secret.
Famous related terms
- Timing attack —
timing attack = measure duration + infer secret— the general family; constant-time comparison is one defense against one member of it. - Side channel —
side channel ≈ information leak through a non-obvious observable (time, power, EM, cache state)— timing is the most software-accessible kind. - HMAC —
HMAC = hash + secret key— the most common reason you’d reach for constant-time compare in a webhook handler. Different from a password hash; see password hashing. - Spectre / Meltdown —
Spectre ≈ side channel via speculative execution + cache timing— same family of bug, weaponized at the CPU microarchitecture level. - AES-NI —
AES-NI = AES rounds as CPU instructions— hardware support added partly so AES implementations could stop using secret-indexed lookup tables that leak via the cache.
Going deeper
- Brumley & Boneh, Remote Timing Attacks Are Practical (USENIX Security, 2003) — the primary source for “can you really measure this over a network,” and the paper that ended the excuse.
- BearSSL’s notes on constant-time programming (Thomas Pornin) — the best explainer for “why is this so much harder than the four-line loop,” with the compiler and hardware caveats spelled out.
- The docs for whichever function you actually call — Python’s
hmac.compare_digest, Go’scrypto/subtle— for the one question only they can answer: what does this implementation promise, and under what conditions does it fall back to a non-constant-time path?