The Timing Attack Hiding in Your Token Comparison
String comparison in PHP short-circuits: === walks both strings and bails at the first differing byte. Great for performance. Terrible for secrets - because "how long the comparison took" now encodes "how many leading characters were right". An attacker who can measure response times can guess a token character by character, turning an impossible brute-force into a linear one.
Is that measurable over the internet, with all its jitter? With enough samples and statistics, more often than you'd like. The defense costs one function call, so the interesting engineering question isn't "is my endpoint attackable" - it's "why would I ever leave this open?"
The fix
// vulnerable: comparison time depends on how correct the guess is
if ($_GET['token'] === $storedToken) { ... }
// constant-time: always compares every byte
if (hash_equals($storedToken, $userToken)) { ... }
hash_equals compares in time dependent only on length, never on content. Argument order matters conceptually: the known secret first, user input second (the function's timing is safe either way, but the signature documents intent).
The real-world case: webhook signatures
This is where most working developers actually meet timing-safe comparison. Stripe, GitHub, PayPal - every webhook provider signs payloads with HMAC, and every verification looks like this:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
Compute your own HMAC over the raw body, compare timing-safe. Copy-pasting a === here is the classic subtle mistake - it works perfectly in testing and leaks in production.
The companion mistakes
- Tokens in the database: if an attacker can time your query (WHERE token = ?), index lookups aren't constant-time either. The robust pattern: store hash('sha256', $token), look up by a separate public id, then hash_equals the hashes. Bonus: a leaked database no longer contains usable tokens.
- Passwords: never hand-compare at all - password_verify() is already timing-safe and handles the salt/algorithm details. While you're in that code, check password_needs_rehash() to transparently upgrade old hashes at login.
- Generating tokens: random_bytes(32), not uniqid(), not md5(mt_rand()). Guessable tokens make comparison timing academic.
Security-sensitive comparison is a solved problem with a dedicated function - the same philosophy as auto-escaping template tags: make the safe path the obvious one-liner, and the vulnerable version starts looking like the extra work.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.