Timing Attacks and Constant-Time Compare, Measured
Security lecture 6: == vs hash_equals and compare_digest timed by matching prefix, in-process and over loopback HTTP, plus a login that leaks which users exist.
Series: Security Basics lecture 6/6 (advanced). This last lecture is for anyone who has written if ($token === $expected) and then read somewhere that it should be hash_equals. You will measure, on your own machine, how much a comparison leaks by matching prefix, whether that leak survives an HTTP round trip, and which timing leak in a typical web app is actually big. The lecture ends with a checklist for the whole series.
Timing attacks sound like a lab curiosity, and for some code they are. For other code they are a one-request bug. In OWASP Top 10 terms they sit under A02 Cryptographic Failures (comparing MACs and tokens) and A07 Identification and Authentication Failures (logins that reveal which accounts exist). The point of this lecture is to tell those cases apart with numbers rather than folklore, and to report the numbers honestly even where the signal is lost in the noise.
Setup: Apple Silicon Mac (M1 Pro), Python 3.9.6 (time.perf_counter_ns), PHP 8.5.6 (hrtime) and php -S 8.5.6 bound to 127.0.0.1:18150. Every measurement ran against code and data created for this lecture on this machine. Seeds were fixed (random.seed(2026), mt_srand(2026)); measurement order was shuffled so that drift affects every case equally. Paths were shortened to ~/demo/.
What a timing attack needs
A comparison like a == b usually stops at the first byte that differs. If the attacker controls b and can measure the time, they learn how many leading bytes were right. With a 64-character hex token that turns one impossible guess (16^64) into 64 small guesses of 16 candidates each.
Three things decide whether that works in practice:
- Signal: how much extra time one more matching byte costs. A
memcmpover 16-byte chunks costs almost nothing per byte; a hand-written loop in an interpreted language costs much more. - Noise: everything else that varies (scheduler, network, the server's other work). The attacker fights it with repetition and statistics.
- Samples: noise shrinks roughly with the square root of the number of measurements. So a signal ten times smaller needs about a hundred times more requests.
We measure all three. For every case we report the median (robust against outliers) and the IQR (interquartile range: the spread of the middle half of the measurements).
In-process: Python == vs hmac.compare_digest
The first script times both comparisons against a secret, with guesses whose first k bytes are right and byte k is wrong.
# ~/demo/t_inproc.py — time == and compare_digest by matching-prefix length
import hmac, random, statistics, sys, time
random.seed(2026)
L = int(sys.argv[1]) if len(sys.argv) > 1 else 64
secret = bytes(random.choice(b"0123456789abcdef") for _ in range(L))
def guess(k): # first k bytes right, byte k wrong
if k >= L: return bytes(bytearray(secret)) # equal value, different object
return secret[:k] + b"x" + secret[k+1:]
prefixes = sorted({0, L//4, L//2, 3*L//4, L-1, L})
cands = {k: guess(k) for k in prefixes}
INNER, REPS = 2000, 301
fns = {"==": lambda a, b: a == b, "compare_digest": hmac.compare_digest}
res = {(f, k): [] for f in fns for k in prefixes}
order = list(res)
for r in range(REPS):
random.shuffle(order) # interleave so drift hits all cases
for f, k in order:
fn, g = fns[f], cands[k]
t0 = time.perf_counter_ns()
for _ in range(INNER):
fn(secret, g)
res[(f, k)].append((time.perf_counter_ns() - t0) / INNER)
for (f, k), v in res.items():
q = statistics.quantiles(v, n=4)
print(f"{f:>15} prefix={k:>5}: median {q[1]:7.2f} ns IQR {q[2]-q[0]:5.2f} ns")
Our first run had a bug worth showing. We built the "fully correct" guess with bytes(secret), and == on it was as fast as a miss on byte 0. The reason: bytes() of a bytes object returns the same object, and CPython's == returns immediately when both sides are the same object. The fix is the bytearray copy above. A benchmark that compares an object with itself measures nothing.
With a 64-byte secret (301 repetitions × 2,000 calls):
| Guess | == median (IQR) |
compare_digest median (IQR) |
|---|---|---|
| 0 bytes right | 84.42 ns (2.57) | 89.50 ns (1.72) |
| 16 bytes right | 86.12 ns (1.54) | 89.46 ns (1.99) |
| 32 bytes right | 87.08 ns (1.56) | 89.48 ns (1.71) |
| 63 bytes right | 87.17 ns (1.74) | 89.48 ns (1.59) |
| all 64 right | 87.04 ns (1.82) | 89.48 ns (1.84) |
== moved by about 2.8 ns in total, roughly the size of its own IQR. The step happens between 0 and 32 bytes and then stays flat, which fits a memcmp that compares in large chunks. compare_digest stayed within 0.1 ns at every prefix. With a 4,096-byte secret the leak becomes obvious: == went from 82.98 ns (0 bytes right) to 205.33 ns (4,095 right), while compare_digest stayed between 1,861 and 1,868 ns. Note the cost of constant time there: about 9× slower than == at 4 KiB, but only 2 to 5 ns slower for a normal 64-byte token.
In-process: PHP ===, hash_equals and a hand-written loop
PHP gets a third contender: the character loop that people write when they "implement it properly" themselves.
<?php
// ~/demo/t_inproc.php (excerpt) — the comparison under test
function loop_eq(string $a, string $b): bool { // naive early-exit compare
if (strlen($a) !== strlen($b)) return false;
for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
return true;
}
// timed exactly like the Python script: 301 shuffled reps x 2,000 calls, hrtime(true)
| Guess (64-byte secret) | === |
hash_equals |
hand-written loop |
|---|---|---|---|
| 0 bytes right | 25.94 ns | 67.85 ns | 61.56 ns |
| 16 bytes right | 26.29 ns | 67.88 ns | 258.81 ns |
| 32 bytes right | 26.85 ns | 67.85 ns | 441.54 ns |
| 48 bytes right | 27.21 ns | 67.85 ns | 623.75 ns |
| 63 bytes right | 27.23 ns | 67.85 ns | 795.17 ns |
IQRs were 0.15 to 6 ns. Three different behaviours:
===leaks, but very little: 1.35 ns from 0 to 63 matching bytes.hash_equalsis flat to within 0.03 ns.- The loop leaks about 11.6 ns per matching byte ((795.17 − 61.56) / 63). That is the textbook timing attack, written with good intentions.
Over loopback HTTP: does the signal survive?
In-process numbers are the best case for an attacker. A real attacker sees the whole HTTP round trip. So we put the same three comparisons behind php -S on 127.0.0.1 and measured from a separate Python client. This is our own server on our own machine; nothing else was involved.
<?php
// ~/demo/router.php — php -S 127.0.0.1:18150 router.php (local demo server only)
const SECRET = '3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19'; // demo value
function loop_eq(string $a, string $b): bool {
if (strlen($a) !== strlen($b)) return false;
for ($i = 0; $i < strlen($a); $i++) if ($a[$i] !== $b[$i]) return false;
return true;
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($path === '/check') {
$t = (string)($_GET['t'] ?? '');
$ok = match ($_GET['mode'] ?? '') {
'eq' => $t === SECRET,
'hash' => hash_equals(SECRET, $t),
'loop' => loop_eq(SECRET, $t),
default => false,
};
http_response_code($ok ? 200 : 403);
echo $ok ? "ok\n" : "no\n";
}
# ~/demo/t_http.py — one new connection per request, cases interleaved
# usage: python3 t_http.py 3000 eq,hash,loop 0,32,63
import http.client, random, statistics, sys, time
random.seed(2026)
SECRET = "3f9c2a7e5b1d4c8f0a6e2b9d7c1f5a3e8b0d4f6a2c9e7b1d5f3a0c8e6b4d2f19"
N = int(sys.argv[1])
modes = sys.argv[2].split(",")
ks = [int(k) for k in sys.argv[3].split(",")]
cases = [(m, k) for m in modes for k in ks]
res = {c: [] for c in cases}
order = cases * N
random.shuffle(order)
for mode, k in order:
conn = http.client.HTTPConnection("127.0.0.1", 18150)
t0 = time.perf_counter_ns()
conn.request("GET", f"/check?mode={mode}&t={SECRET[:k]}x{SECRET[k+1:]}")
conn.getresponse().read()
res[(mode, k)].append((time.perf_counter_ns() - t0) / 1000) # microseconds
conn.close()
def boot_ci(a, b, B=2000): # 95% CI of median(b) - median(a)
rnd = random.Random(7)
d = sorted(statistics.median(rnd.choices(b, k=len(b))) - statistics.median(rnd.choices(a, k=len(a)))
for _ in range(B))
return d[int(.025 * B)], d[int(.975 * B)]
for m in modes:
for k in ks:
q = statistics.quantiles(res[(m, k)], n=4)
print(f"{m:>5} prefix={k:>2}: median {q[1]:6.1f} us IQR {q[2]-q[0]:5.1f} us")
a, b = res[(m, ks[0])], res[(m, ks[-1])]
lo, hi = boot_ci(a, b)
d = statistics.median(b) - statistics.median(a)
print(f" difference {d*1000:+.0f} ns, 95% CI [{lo*1000:+.0f}, {hi*1000:+.0f}] ns")
With python3 t_http.py 3000 eq,hash,loop 0,32,63 (27,000 requests, 11.2 s):
| Mode | 0 right | 32 right | 63 right | median(63) − median(0), 95% CI |
|---|---|---|---|---|
=== |
192.6 µs | 192.3 µs | 191.5 µs | −1,146 ns [−1,875, −250] |
hash_equals |
191.7 µs | 192.8 µs | 192.5 µs | +770 ns [−104, +1,459] |
| loop | 192.1 µs | 192.7 µs | 192.7 µs | +541 ns [−314, +1,355] |
The IQR of every case was about 20 µs, more than 20 times the loop's whole 730 ns signal. Look at the === row: its interval excludes zero, but in the wrong direction. A comparison cannot get faster when more bytes match, so this is noise and drift that happened to line up, which is exactly what you should expect sometimes when you test three things at once. An earlier run of the same experiment (before we tidied the script) had a 60 µs IQR and all three intervals contained zero. A single "it was 1 µs slower" observation proves nothing, in either direction.
We then ran only the loop and hash_equals, at 0 and 63 matching bytes, with 20,000 requests per case: python3 t_http.py 20000 hash,loop 0,63 (80,000 requests, 44.3 s):
| Mode | 0 right | 63 right | Difference, 95% CI |
|---|---|---|---|
hash_equals |
190.3 µs (IQR 14.0) | 190.5 µs (IQR 14.2) | +126 ns [−84, +416] |
| loop | 190.3 µs (IQR 14.3) | 191.2 µs (IQR 14.1) | +875 ns [+624, +1,084] |
Now the loop's leak shows, close to the 730 ns measured in-process, and the interval excludes zero. The earlier run agreed: loop +688 ns [+208, +1,186], hash_equals −166 ns [−625, +292]. So the honest summary is:
- Over loopback, the whole 63-byte difference of a slow loop was detectable with tens of thousands of requests.
- Recovering a token needs the per-byte difference, about 11.6 ns. Our 95% interval was about ±230 to ±490 ns wide at 20,000 requests. Narrowing it to about ±6 ns would need roughly (230/6)² ≈ 1,500 to (490/6)² ≈ 6,700 times more samples, tens of millions to around 10^8 requests per candidate byte. That is an extrapolation, not a measurement.
- For PHP's
===(1.35 ns over the whole token), we saw nothing at all.
None of this makes === safe to keep. Attackers on the same network or the same cloud host have less noise than a loopback client running Python, better statistics than a median, and much more patience than our 44 seconds. Published research has shown remote timing attacks against small differences under good conditions. The fix costs nanoseconds, so the decision is easy.
The leak that is easy to measure: who has an account
The big timing leaks in web apps are rarely in string comparison. They come from doing expensive work for some inputs and not for others. The classic is a login that only runs password_verify when the user exists. The same router has a /login endpoint:
// ~/demo/router.php, continued: append below the /check block
// users.php returns ['mina' => '$2y$10$…'] (bcrypt cost 10)
// bcrypt hash of a random string nobody knows, same cost as the real hashes
const DUMMY_HASH = '$2y$10$KDMs6ijzOIwe1yxm5R15tOxeShXOlOTt33nqyt3WzZ5xQOkehLuLG';
if ($path === '/login') {
$users = require __DIR__ . '/users.php';
$u = (string)($_POST['user'] ?? '');
$p = (string)($_POST['pass'] ?? '');
if (($_GET['fix'] ?? '') === '1') {
$hash = $users[$u] ?? DUMMY_HASH; // FIX: always one bcrypt verify
$ok = password_verify($p, $hash) && isset($users[$u]);
} else {
$ok = isset($users[$u]) && password_verify($p, $users[$u]); // BUG: early return
}
http_response_code($ok ? 200 : 401);
echo $ok ? "welcome\n" : "invalid\n";
}
We sent 300 wrong-password logins per case, interleaved, each on a new connection:
buggy user=mina : status [401], median 59.62 ms IQR 0.60 ms
buggy user=nobody: status [401], median 0.47 ms IQR 0.16 ms
fixed user=mina : status [401], median 59.63 ms IQR 0.58 ms
fixed user=nobody: status [401], median 59.64 ms IQR 0.58 ms
Same status, same body, and a 127-fold difference in time. One request per username is enough to tell which accounts exist; no statistics needed. With the dummy hash, the fixed endpoint answers both users within 0.01 ms of each other, far inside the IQR. A second run gave the same picture (59.61 ms vs 0.45 ms buggy; 59.62 vs 59.58 ms fixed). The same pattern appears in password-reset forms ("send mail only if the user exists"), sign-up checks and API keys looked up and then verified. The fix is always the same idea: do the same expensive work on both paths, and return the same response. Rate limiting (see lecture 2) makes the remaining leaks more expensive to exploit.
Common mistakes and what actually happens
| Mistake | What we saw | Fix |
|---|---|---|
| Hand-written "secure" compare loop | 11.6 ns per matching byte; detectable over loopback | hash_equals / hmac.compare_digest |
hmac.compare_digest on non-ASCII str |
TypeError: comparing strings with non-ASCII characters is not supported (with 'café-7f3a') |
Compare bytes, or compare hex digests |
hash_equals with null from a missing field |
TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given |
Cast or validate input first: (string)($_GET['t'] ?? '') |
Arguments swapped in hash_equals |
Still correct, but the length of the user string may influence timing | Known value first, user input second |
| Expensive work only for valid users | 0.47 ms vs 59.62 ms | Dummy hash; identical responses |
Benchmarking x == x |
Identity shortcut, 0-byte timing for "all correct" | Compare distinct objects |
| Looking up a token in the DB by its value | The index comparison is not constant-time (described, not measured) | Store SHA-256 of the token and look up by that |
The last row deserves code. Store only a hash of the token; the user presents the token; you hash it and compare hashes. An attacker cannot steer the prefix of a SHA-256 output, so a prefix leak is useless, and the hex digest is ASCII, so compare_digest never raises:
import hashlib, hmac, secrets
token = secrets.token_urlsafe(32) # sent to the user once
stored = hashlib.sha256(token.encode()).hexdigest() # only this goes in the DB
def check(presented: str) -> bool:
h = hashlib.sha256(presented.encode()).hexdigest()
return hmac.compare_digest(h, stored)
print(len(token), check(token), check(token[:-1] + "x"), check("café"))
# 43 True False False
Both hash_equals and compare_digest still reveal the length of the secret in some cases (their documentation says so). That is fine for fixed-length tokens and digests, which is another reason to compare hashes.
Other side channels (described, not run)
Time is one channel among several. We did not measure these, but you should recognise them:
- Different error messages or status codes: "unknown user" vs "wrong password", or 404 vs 403 for objects that exist. This leaks with zero statistics. Return the same message.
- Response size: compressing a page that contains both a secret and attacker-controlled text lets the size reveal the secret (the idea behind the CRIME and BREACH attacks on HTTPS compression). See HTTPS and TLS for the transport side.
- CPU caches and branch prediction: table-based AES implementations and secret-dependent branches leak through shared hardware (Spectre-class and cache-timing attacks). This is a reason to use vetted crypto libraries rather than your own AES.
- Work that depends on secret data: early exits in parsers, regexes that backtrack differently, database queries that return early.
- Rate and lockout behaviour: a lockout that triggers only for existing accounts is another existence oracle.
Choosing the right comparison
| What you compare | Use | Note |
|---|---|---|
| Password | password_verify (PHP), the library's verify function |
Run it on a dummy hash for unknown users |
| HMAC/signature (webhooks, JWT, CSRF) | hash_equals($expected, $given), hmac.compare_digest |
Recompute the expected value, never trust a provided one |
| API key or reset token in a DB | SHA-256 of the token as the stored key | Look up by hash, then compare hashes |
| Short numeric code (OTP) | Constant-time compare plus attempt limits | The limit matters more than the compare |
| Non-secret data | == / === is fine |
Not every comparison needs to be constant-time |
Series wrap-up: the checklist
Six lectures, one page. If a new teammate asks what to check before a release, this is the list:
- Encoding is not encryption. Base64 and hex are reversible by anyone (lecture 1).
- Passwords: bcrypt or Argon2id via
password_hash/password_verify, never a fast hash; rehash when the cost changes; watch bcrypt's 72-byte limit (lecture 2). - Randomness:
secrets/random_bytesfor tokens, neverrandom/mt_rand, never seeded with time; 128 bits or more (lecture 3). - Signed tokens: pin the algorithm, reject
alg: none, use a 32-byte random HMAC key, checkexpandaud(lecture 4). - Encryption: AEAD only (AES-256-GCM or XChaCha20-Poly1305), a fresh random nonce per message, a 16-byte tag checked, keys outside the DB (lecture 5).
- Comparisons:
hash_equals/compare_digestfor secrets, the same work and the same response for valid and invalid users (this lecture). - The rest of web security: prepared statements against SQL injection (PDO and SQLite), output escaping and CSRF tokens (XSS, CSRF and sessions), cookie flags (headers, cookies and sessions), and HTTPS everywhere (TLS). For auditing an old PHP codebase without changing its output, see the SQLi/XSS audit of a legacy PHP app.
Honest limits and version notes
- Measured on one M1 Pro with Python 3.9.6, PHP 8.5.6 and
php -S8.5.6.php -Sis single-process and closed every connection; a production server with keep-alive, TLS and other traffic has a different noise profile, and could be either easier or harder to attack. - The loopback client was Python with
http.client, which adds its own jitter. A C client or a raw socket would reduce noise; we did not try one. - The "10^7 to 10^8 requests per byte" figure is an extrapolation from the 20,000-request intervals, assuming noise falls with the square root of the sample count.
- Loopback noise changed between runs (IQR 60 µs in one, 20 µs in the next). Run your own test more than once before you believe it.
hash_equalsexists since PHP 5.6 andhmac.compare_digestsince Python 3.3; we only ran the versions above.- The side channels listed in the previous section were described, not run.
Exercises
1. A webhook handler checks a signature with if ($_SERVER['HTTP_X_SIGNATURE'] == hash_hmac('sha256', $body, $secret)). List everything wrong with that line and rewrite it.
2. Your password-reset form says "If that e-mail exists, we sent a link", but it takes 300 ms when the address exists (it renders and sends a mail) and 5 ms when it does not. What does an attacker learn, and how do you fix it without changing the message?
Answers
1. (a) == is not constant-time, and it is loose: PHP 8.5.6 still compares numeric-looking strings as numbers, so "0e1234" == "0e5678" is true. A hex HMAC that happens to be 0e followed only by digits would match any other such string. (b) The header may be missing, giving null. (c) The provided value comes first, so the comparison runs over the attacker's string. Rewrite: $given = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? ''); $expected = hash_hmac('sha256', $body, $secret); if (!hash_equals($expected, $given)) { http_response_code(401); exit; }. Also compute the HMAC over the raw body exactly as received.
2. The 60-fold time difference tells the attacker which addresses have accounts, exactly like the login demo. Fix: respond immediately in both cases and do the work in the background (a queue or a job run after the response is sent), so the request time does not depend on whether the account exists. Add a rate limit per IP and per address.
Next
- Previous: Security Basics lecture 5/6: AES-GCM done right
- Start of the series: Security Basics lecture 1/6: hashing vs encryption vs encoding
- The rest of the series: password hashing, secure random tokens, JWT HS256 pitfalls
- Python basics used in the scripts: Python types and mutability
- All lecture series: programming learning roadmap
FAQ
Is comparing an API token with == really exploitable?
It leaks, but how much depends on the comparison. PHP's === on a 64-byte token changed by only about 1.4 ns between no match and a full prefix, and over loopback HTTP we could not see it. A hand-written character loop leaked about 11.6 ns per matching byte, and its full 63-byte difference was detectable over loopback HTTP with 20,000 requests per case. Use hash_equals or hmac.compare_digest anyway; they cost a few tens of nanoseconds.
Which timing leak is easiest to measure in a web app?
Doing expensive work only for some inputs. Our login returned early for unknown usernames: 0.47 ms instead of 59.62 ms with bcrypt cost 10, a 127-fold difference visible with a single request. Running password_verify against a dummy hash for unknown users made all four cases take about 59.6 ms.
Why does hmac.compare_digest raise TypeError on my token?
For str arguments, compare_digest accepts ASCII only. Any non-ASCII character raises TypeError: comparing strings with non-ASCII characters is not supported. Compare bytes instead (encode both sides to UTF-8), or compare SHA-256 hex digests, which are always ASCII.