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:

  1. Signal: how much extra time one more matching byte costs. A memcmp over 16-byte chunks costs almost nothing per byte; a hand-written loop in an interpreted language costs much more.
  2. Noise: everything else that varies (scheduler, network, the server's other work). The attacker fights it with repetition and statistics.
  3. 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) 

[[fig:sec-timing-loop]]

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_equals is 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:

[[fig:sec-timing-login]]

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

[[fig:sec-timing-where]]

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:

  1. Encoding is not encryption. Base64 and hex are reversible by anyone (lecture 1).
  2. 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).
  3. Randomness: secrets/random_bytes for tokens, never random/mt_rand, never seeded with time; 128 bits or more (lecture 3).
  4. Signed tokens: pin the algorithm, reject alg: none, use a 32-byte random HMAC key, check exp and aud (lecture 4).
  5. 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).
  6. Comparisons: hash_equals/compare_digest for secrets, the same work and the same response for valid and invalid users (this lecture).
  7. 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 -S 8.5.6. php -S is 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.
  • ...