Constant-Time Comparison
Web developmentsecurity / backend engineering
Note: manually requested by Hamzawi (off the daily rotation)
---
Constant-Time Comparison — the == that leaks your API token
✍️ Off-schedule bonus: this one wasn't picked by the daily rotation — Hamzawi requested it manually.
The bug is one character wide
if (provided_token == expected_token): # ← leaks
grant_access()
== on strings/bytes short-circuits: it returns False the instant two bytes differ. So a token that matches the first 3 bytes takes measurably longer to reject than one that matches 0. Compare byte-by-byte, and an attacker recovers your secret one byte at a time — 256 × 32 guesses instead of 256^32.
Build the fix in 5 lines
def ct_eq(a: bytes, b: bytes) -> bool:
if len(a) != len(b):
return False
diff = 0
for x, y in zip(a, b):
diff |= x ^ y
return diff == 0
What each line teaches:
len(a) != len(b)— length is NOT hidden by this trick. You leak the secret's length, which is almost always fine (token lengths are public). If it isn't fine, compare HMACs of both values instead.diff = 0— an accumulator, not a flag you can break out of.x ^ y— XOR is 0 only when the bytes are equal. Equality without branching.diff |= ...— OR sticks: once any bit is set it never clears. This is why there is no early exit — every byte is always visited.return diff == 0— one comparison at the end, on a value whose timing no longer depends on where the mismatch was.
"But it's only nanoseconds"
That's the part people get wrong. A single sample is noise; the attacker doesn't take one. Averaging over ~10k requests pulls a sub-microsecond bias out of the jitter. Crosby et al. (2009) measured reliable distinctions of ~100ns over LAN and ~30µs over the internet. Cloud co-tenancy makes it easier, not harder.
Don't hand-roll it — use the stdlib
Python: hmac.compare_digest(a, b)
Node: crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) # throws if lengths differ
Go: subtle.ConstantTimeCompare(a, b)
Java: MessageDigest.isEqual(a, b)
Why: your 5-liner is correct in Python, but a JIT or an optimizing C compiler is free to reintroduce a branch. The stdlib versions are written to survive that.
The gotchas that actually bite
- It only matters when the attacker controls one side and the other is a secret: API keys, webhook HMAC signatures, password-reset tokens, CSRF tokens, session IDs.
- You do NOT need it for password verification —
bcrypt.compare/argon2.verifyalready handle it, and the hash isn't attacker-guessable byte-by-byte anyway. - The leak often moves up a layer.
SELECT * FROM keys WHERE token = ?pushes the comparison into the database, where index traversal has its own timing signature. Look the row up by a public id, then constant-time-compare the secret in app code. - Length check first still leaks length — and in Node,
timingSafeEqualthrows on a length mismatch. Catch it and return false, or you've turned a leak into a 500.
Your exercise
Write a webhook verifier: given a raw request body, a shared secret, and an X-Signature: sha256=<hex> header, compute the HMAC and compare safely.
Two questions to answer while you do it:
— Should you hex-decode the header before comparing, or hex-encode your own digest? (One of these hands an attacker a parsing error channel.)
— Does it still leak anything if you return False early on a malformed header?