Security / security / authentication / side channels
The Timing Leak That Matters Skips Work, Not Bytes
That timing attacks are about comparison loops, so replacing == with a constant-time compare closes the channel. The difference a byte comparison makes is about 2.4 nanoseconds, which no filter recovers across a network at any practical sample count. The differences that are trivially readable are branches that skip work — an early return before the password hash is about 240 milliseconds, a hundred million times larger, and two requests find it. Teams add hmac.compare_digest and leave the account-enumeration oracle in place.
Almost every team that has thought about timing attacks has done the same
one thing: replaced == on a secret with a constant-time
comparison. That change closes a channel worth about
2.4 nanoseconds per byte. In the same login handler, returning early
because the username does not exist skips the password hash and closes a
channel worth about 240 milliseconds — a hundred million times
larger, and readable from the other side of the planet in two requests.
Whether a timing difference leaks is not a property of the code. It is a ratio: the size of the difference, against the noise that is left after an attacker filters many samples. Both halves are measurable, and the second one has been measured — Crosby, Wallach and Riedi put the resolution of a remote attacker at 15 to 100 microseconds across the internet and as good as 100 nanoseconds over a local network.
The panel below is that measurement, run as an experiment rather than quoted. It draws real samples from a skewed response-time distribution, applies the attacker's filter, and reports two things: how much of the true difference survives the filter, and how much noise is left. The smallest readable difference falls out of those two numbers. Six real leaks from one login endpoint are then checked against it.
Four moves. At the defaults — 64 samples, across the internet, averaged — the floor is 1.3 ms, and the 240 ms early return is already an open book. Take samples per measurement to 1,024 and the floor drops to 196 µs. Now change nothing but the filter, from the mean to the 5th percentile: 33 µs, from the same 2,048 requests. Then try the minimum, which by every intuition should be the best filter of all.
readable at this resolution · below the noise floor. The scale is logarithmic and spans ten orders of magnitude. The six durations are one deployment's measurements, not universal constants; the noise model is calibrated so that 1,000 samples filtered at the 5th percentile resolve about 200 ns in a datacentre and about 30 µs across the internet, which are the figures Crosby, Wallach and Riedi measured.
Two results worth stopping on. The mean is a bad filter here, and not by a little: response times are not symmetric around a true value, they are a floor with a long queue-shaped tail above it, so a low percentile reads the floor while the mean reads the tail. And the minimum, which looks like the perfect way to find a floor, collapses — read the "of the signal survives the filter" number when you select it. That is not noise beating it. That is the filter locking on to a fast path that never did the work at all, and therefore never carried the signal.
Resolution is a measured quantity
The argument "network jitter drowns the signal" treats jitter as a wall. It is not a wall; it is a divisor that shrinks with sample count, and the rate at which it shrinks depends on the filter.
Crosby, Wallach and Riedi measured this directly in 2009 and the numbers have aged well because they are about network behaviour rather than CPU speed. Their filters let "an attacker measure events with 15–100 µs accuracy across the Internet, and as good as 100 ns over a local network." Their simulated attacker "was able to reliably distinguish a processing time differences as low as 200ns and 30µs with 1,000 measurements on the LAN and WAN respectively." LAN is their local network; WAN is a host reached across the public internet.
Read that as a budget. A thousand requests — a number no rate limiter notices and no log review flags — buys 30 microseconds of resolution from anywhere on the internet. Anything in your handler that takes longer than 30 microseconds and happens conditionally on a secret is readable by someone who cares enough to send a thousand requests.
They also demolish the assumption underneath the "jitter drowns it" argument. "Does network latency follow a Gaussian distribution? No. The distribution of response time is a highly skewed distribution." That single fact is why the mean is the wrong tool: the mean is an efficient estimator for symmetric noise, and there is no symmetric noise here. The floor is the signal, and the tail is the noise, and averaging mixes them together.
Why the minimum fails, which is the interesting part
If the floor is the signal, take the minimum. The paper anticipates the thought and answers it: "Contrary to expectations, the minimum response time is not the least noisy signal. Low percentile filters exhibit significantly less noise than the minimum response times." For the host in their Figure 4 they measure the minimum at "about three times noisier than the first percentile", and Figure 3 gives the reason in one line: "the minimum response time seems to be poorly correlated to the processing time." Poorly correlated, not merely noisy — which is a statement about signal, not about noise.
Select minimum in the panel and read the second readout rather than the first. The noise does not explode. The signal disappears — the share of the true difference that survives the filter falls towards zero. An estimator that ignores the quantity you are trying to measure is not noisy, it is blind, and no number of samples repairs it.
The mechanism is that the minimum is an order statistic of one sample, so it is decided entirely by the single most anomalous response. Any path that returns without doing the work being measured — a coalesced interrupt, a connection served from a warm buffer, a proxy answering from its own cache — produces a response that carries no signal and is faster than every response that does carry one. The minimum finds that path and locks on to it. A low percentile steps just above the anomalies and lands on the real floor. That is the entire argument for the fifth percentile over the zeroth one, and it is why the paper's best discrimination test compares "the results of two low-percentile filters."
There is a companion instrument in the lesson on password hashing work factors that measures a comparison routine with no network at all and models the jitter as symmetric noise around the true value. That is the right model for a local measurement. It is the wrong model for a network, and the difference between the two models is worth a factor of ten to an attacker.
The quick exit
OWASP's Authentication Cheat Sheet names the pattern. It says the business logic itself "can bring a discrepancy factor related to the processing time taken", because "depending on the implementation, the processing time can be significantly different according to the case (success vs failure) allowing an attacker to mount a time-based attack" — and it calls the shape of the bug a "quick exit".
A quick exit is any return that skips work. In an
authentication handler the work being skipped is almost always the password
hash, and a password hash is deliberately expensive — that is its entire
job. The lesson on
choosing a work factor sets the cost from your
login latency budget, which means the size of this leak is a number you
chose on purpose. A 240 ms hash is 240 ms of signal every time you skip it.
The fix is the one thing that feels wasteful and is not: on the miss path,
run the hash anyway, with the same parameters, and throw the result away.
Django ships this. Its
check_password_with_timing_attack_mitigation helper calls
get_user_model()().set_password(password) when the lookup
found nobody — hashing the submitted password against a throwaway user
object — and the docstring says why: "otherwise runs the default password
hasher to prevent user enumeration attacks". Every framework that
has thought about it does something equivalent, and every framework that
has not, leaks.
Two related exits usually survive the first fix, because they are on different endpoints. Password reset and account signup both branch on whether the address exists, and both are unauthenticated. An enumeration oracle on any endpoint is an enumeration oracle. Once someone has a verified list of your account names, the next step is replaying credentials from someone else's breach, and their success rate goes up by exactly the factor by which your list shortened their candidate set.
What the defences actually buy
Set the mitigation control to add a random delay. The floor moves. It does not become a wall, and the panel prints both numbers so you can see the ratio.
Adding random delay adds a random variable to every sample. Its effect on the attacker is to raise the noise term, which the attacker divides down by taking more samples exactly as before. The cost multiplies; it does not become infinite. It also has a second cost that is not on the panel: every legitimate request now pays the delay too, and the delay has to be drawn fresh each time — a delay derived from the input is not random at all, it is a second oracle.
Set it to hold every response until a fixed deadline and the resolution readout says nothing, because the signal is gone rather than buried. This is the only mitigation on the list that is categorical, and it has real costs: every login pays the budget, the budget has to exceed the slowest legitimate path, and if any path ever overruns it the overrun is itself a signal. It also needs to be a deadline measured from request arrival, not a delay added at the end, or you have simply moved the difference later.
Set it to rate-limit and nothing about the resolution changes at all. The readout for what a measurement costs stretches, and that is the entire effect. Rate limiting is worth having for other reasons, and against a distributed attacker with a thousand source addresses the stretch factor is one thousandth of what the panel shows. Do not count it as a fix for this.
Which leaves the ordering that matters. Remove the branch if you can; equalise the work if you cannot; pad to a deadline if the work genuinely cannot be equalised; and treat noise and rate limits as things that raise the price rather than close the door.
Measuring your own endpoint
You can put a number on your own login handler this afternoon, and the number will be more persuasive in a review than any argument.
- Take 1,000 samples of each variant, not ten. Two lists: 1,000 logins with a username you know exists and a wrong password, 1,000 with a username you know does not exist. Same client, same connection reuse settings, interleaved rather than one batch after the other, so that drift affects both equally.
- Compare the 5th percentiles, not the means. Sort each list and take the value at index 50. If those two numbers differ by more than a few times the spread of the lower tail, you have an oracle. Using the mean here will hide a difference the panel shows an attacker can read.
- Interleave and repeat on another day. A difference that appears in one batch and not the next is drift. A difference that survives interleaving and repetition is your code.
- Do the same on password reset and signup. These are the endpoints the login fix does not touch, and they are the ones with a "we emailed you if that address exists" message that is correct and a response time that is not.
-
Grep for the shape, not the symptom. Any
return,raiseor earlythrowbetween reading a secret and finishing the work is a candidate. In practice: the user lookup, the account-locked check, the multi-factor branch, the tenant lookup, and the "is this API key in the cache" path. - Write the timing test into the suite. Assert that the two percentiles are within a stated tolerance. It is the only way this finding stops coming back, because the next refactor that adds an early return will otherwise reintroduce it silently.
- Keep using the constant-time comparison. Everything above is an argument about priority, not about whether to do it. It costs nothing, it removes a whole class of reasoning from review, and it is genuinely the right call in the one place it matters most — an API token or signature the attacker supplies directly, where the attacker does control the bytes being compared and can walk them one at a time. Set the panel to in the same process, the 5th percentile, and 2,048 samples: the floor is 2.1 ns and the 2.4 ns byte comparison turns red. That is the whole footprint of the leak everybody fixed — a co-resident attacker, or a library you called yourself.
Your login endpoint returns the same generic error for every failure, and you have replaced the password comparison with a constant-time one. An attacker on the public internet sends 2,000 requests. What can they most likely learn?
The same reasoning transfers to any check that runs before the expensive part of a protocol. A server that rejects a client certificate early, and a server that rejects it after doing the signature work, are distinguishable in exactly this way — see what the handshake proves and when it proves it.