Timing attacks have a reputation for being academic, and most of that reputation is deserved. Extracting a secret from microsecond differences in a comparison loop, across the internet, through queueing and scheduling jitter, is genuinely hard.
Then there is the other kind: a response that takes 120 milliseconds when an account exists and 3 milliseconds when it does not. That is not subtle, it needs no statistics, and it is a reliable way to enumerate your users.
This post is the distinction. For whoever has been told to use constant-time comparison and wondered whether it matters.
The coarse case is the one that gets exploited
The shape is almost always the same: you do expensive work only in one branch.
The canonical example is login. A valid username means you fetch the record and run a password hash verification, which is deliberately slow, by design, because that is what makes password hashing useful. An invalid username means you return immediately.
So the difference between "this account exists" and "it does not" is the entire cost of a bcrypt or argon2 verification, which is tens to hundreds of milliseconds. Measurable over any network, with one request each, no repetition required.
The same pattern appears in:
- Password reset, where a valid address triggers a token generation and an email send.
- Invitation or coupon lookup, where a valid code triggers a database write.
- API key validation, where a well-formed key hits the database and a malformed one is rejected by a format check.
- Anything with a cache, where a hit and a miss differ by the cost of the underlying fetch.
That last one is easy to miss and it turns your cache into an oracle for what other people have recently accessed.
Doing the same work is the fix
Not a constant-time string comparison. The work itself has to match.
# leaks existence: the hash only runs for real users
user = find_user(email)
if user is None:
return generic_error()
if not verify_password(user.hash, password):
return generic_error()
# does not: verify against a dummy hash when there is no user
user = find_user(email)
target = user.hash if user else DUMMY_HASH # precomputed, same cost
ok = verify_password(target, password)
if not user or not ok:
return generic_error()
The dummy hash must be generated with the same algorithm and parameters as your real ones, or the costs diverge again. And the error must be identical in both branches, in body and status, because a timing fix paired with a distinguishable message accomplishes nothing.
For operations where matching the work is impractical, such as sending an email, move the expensive part out of the request: enqueue it and return immediately in both branches. That removes the difference and is better design anyway.
A minimum floor, not a random delay
The instinct is to add a random delay. It helps less than it seems, because an attacker who can repeat the request averages the noise away and recovers the underlying difference.
A minimum duration works better: measure the elapsed time and sleep until a fixed floor is reached, so every response takes at least as long as the slowest branch. It costs latency on the fast path, which is the trade, and it is deterministic rather than probabilistic.
Use it where matching the work is genuinely hard, and prefer matching the work where you can.
The fine-grained case, honestly
Byte-by-byte comparison of a secret leaks through timing in principle. Across a network, for a web application, exploiting it is difficult: the differences are microseconds, the noise is milliseconds, and it needs an enormous number of requests that your rate limiting should notice.
Use constant-time comparison anyway, for tokens, signatures and keys. Not because the remote attack is likely, but because it is one function call, it removes the argument entirely, and the threat model changes if an attacker ever gets closer, such as another tenant on shared infrastructure.
import hmac
hmac.compare_digest(expected, provided)
What is not worth doing is restructuring an application around fine-grained timing when the coarse leak in your login endpoint is wide open.
Measure rather than reason
You cannot tell by reading the code, because the difference depends on what the database, the cache and the framework do:
# Existing account versus definitely-not-existing
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{time_total}\n" -X POST https://app.example.com/login \
-d 'email=real@example.com&password=wrong'
done | sort -n | head -3
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{time_total}\n" -X POST https://app.example.com/login \
-d 'email=nobody-9f3a1c@example.com&password=wrong'
done | sort -n | head -3
Compare the fastest few from each, because the minimum is less noisy than the mean. A consistent gap of more than a few tens of milliseconds is a finding you can act on. A gap you cannot see across twenty requests is not worth chasing.
The concession
A minimum-duration floor makes your login endpoint slower for everyone, and on a high-volume service that is real cost in both latency and capacity. There is also a legitimate argument that user enumeration is low severity for many products, since email addresses are often discoverable anyway, and spending engineering effort here is spending it in the wrong place.
That argument is stronger for consumer products than for anything where membership itself is sensitive. If knowing that a particular person has an account with you is information worth protecting, and for a healthcare, legal or financial product it usually is, then this is worth the milliseconds.
The implication
The useful split is between differences measured in microseconds, which are mostly a correctness concern, and differences measured in tens of milliseconds, which are a working enumeration tool.
Run the two loops above against your own login endpoint. The answer takes a minute and it tells you which kind you have.