Here’s a line of code I still find in reviews every few months:
if request.headers["X-Signature"] == hmac.new(key, body, "sha256").hexdigest():
process(body)
It looks fine. The HMAC is correct, the key is secret, the comparison is a comparison. The bug is that == on strings returns as soon as it finds the first byte that differs, so the time it takes leaks how many leading bytes of the attacker’s guess were right. Given enough measurements, an attacker can recover a valid signature one byte at a time without ever knowing the key.
How the attack works

A short circuiting comparison does roughly one unit of work per matching byte before it bails out. If the attacker submits a signature starting with a, then b, then c and so on, the guess with the correct first character takes very slightly longer than the other fifteen hex digits. Fix that character, move to the second one, repeat. For a 64 character hex digest that’s at most 16 x 64 = 1,024 candidate positions instead of 16^64 possible values. The brute force problem goes from impossible to linear.
The obvious objection is that a few nanoseconds per byte can’t survive network jitter. That objection is weaker than it sounds. Jitter is noise, and noise averages out with repetition. Crosby, Wallach and Riedi’s 2009 paper “Opportunities and Limits of Remote Timing Attacks” showed that with careful statistics an attacker could distinguish differences in the tens of microseconds across the internet and around 100 nanoseconds on a LAN. In 2020, “Timeless Timing Attacks” by Van Goethem and colleagues removed much of the network jitter entirely by sending two requests in the same HTTP/2 packet and only looking at which response came back first. And an attacker who can get a VM in the same cloud region as your service is not measuring across the internet at all.
I treat any comparison against a secret value as attackable. Whether a particular one is exploitable this week is not the question I want to be answering during an incident.
A real example: Rails basic auth
CVE-2015-7576 is a clean case. Action Controller’s http_basic_authenticate_with compared the supplied username and password with a normal string comparison, which, in the words of the advisory, made it easier for remote attackers to bypass authentication by measuring timing differences. The fix, released in January 2016 across the supported Rails branches, switched to a constant-time comparison.
TLS has a much more famous version of the same class of problem. Lucky Thirteen (CVE-2013-0169) recovered plaintext from CBC mode TLS because the time taken to check the MAC depended on how much padding was removed. That one took years of careful patching across every TLS library, and it’s a big part of why TLS 1.3 dropped CBC cipher suites entirely.
What constant-time comparison does
The idea is to do the same amount of work regardless of where, or whether, the inputs differ. Instead of returning at the first mismatch, you accumulate the differences and only look at the result at the end:
int ct_equal(const unsigned char *a, const unsigned char *b, size_t len)
{
unsigned char diff = 0;
for (size_t i = 0; i < len; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
XOR is zero only when two bytes match, and OR-ing every result together means a single mismatch anywhere leaves diff non-zero. There’s no branch that depends on the data inside the loop, so the loop takes the same time for a guess that’s wrong in the first byte as for one that’s wrong in the last.
Please don’t write this yourself in production, though. Optimising compilers are allowed to notice that once diff is non-zero the answer is decided, and to turn your careful loop back into an early exit. Library implementations use compiler barriers or assembly to stop that, and they get tested for it. Use them:
import hmac
if hmac.compare_digest(received_sig, expected_sig):
process(body)
import "crypto/subtle"
if subtle.ConstantTimeCompare(receivedMAC, expectedMAC) == 1 {
process(body)
}
const crypto = require("crypto");
const a = Buffer.from(receivedSig, "hex");
const b = Buffer.from(expectedSig, "hex");
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
process(body);
}
Other ecosystems have the same thing: hash_equals in PHP, MessageDigest.isEqual in Java, ActiveSupport::SecurityUtils.secure_compare in Rails, CRYPTO_memcmp in OpenSSL and sodium_memcmp in libsodium.
Length is a leak too
Most constant-time functions are only constant-time for inputs of equal length. Go’s ConstantTimeCompare documents that it returns 0 immediately when lengths differ, and Node’s timingSafeEqual throws, which is why the JavaScript example checks length first. For HMACs and hashes that’s fine, since the length is public anyway.
For things like API tokens where you’d rather not reveal the length, a common trick is to hash both values first (SHA-256 or an HMAC with a per-process key) and compare the fixed length digests.
The comparisons people forget
Token lookups are the subtle one. If your API looks up a key with SELECT ... WHERE api_key = ?, the database’s index search is itself a comparison that can leak timing about the stored values. The pattern I prefer is a split token: the first part is a non-secret identifier you look up by, and the second part is a secret you verify against a stored hash with a constant-time comparison. A leaked database then also doesn’t contain usable tokens, which is a nice side benefit.
Login endpoints leak in a different way. If you return immediately for an unknown username but spend 200 ms running Argon2 for a known one, the response time tells an attacker which accounts exist. Run a dummy hash against a fixed value when the user isn’t found, so both paths cost the same. This ties back to the password storage post from earlier this week: a slow hash makes the timing difference larger, not smaller.
Error paths count as well. Returning “invalid padding” and “invalid MAC” as different errors, or failing at different points, turns a timing oracle into an even easier one. Verify the MAC first, over the whole message, and give one error for everything.
What I check in review
Any ==, !=, equals(), strcmp or memcmp that touches a signature, MAC, token, password reset code, session identifier or API key gets flagged. A Semgrep rule for this takes a few minutes to write and is worth running in CI, because the bug is easy to reintroduce. It costs nothing to fix, it doesn’t change behaviour, and it removes a whole class of attack that doesn’t need anything clever beyond a stopwatch and patience.

Leave a Reply
You must be logged in to post a comment.