Timing attack — exploiting response time differences to leak secrets
Concept
Timing attack — a side-channel attack that infers secret information by measuring HOW LONG a computation takes, rather than observing its output.
How it works with string comparison:
if ($userToken === $storedToken) { ... }Most programming languages short-circuit string comparison — they stop as soon as a character doesn't match. Comparing "abc" with "xyz" is faster than comparing "abc" with "abd" (first char mismatch vs last char mismatch). An attacker can measure these timing differences to guess the secret one character at a time.
Example attack:
- Attacker tries
"a"— comparison takes 1ns. First char doesn't match. - Attacker tries
"h"— comparison takes 2ns. First char matches! - Attacker continues:
"ha","hb"... until the whole secret is reconstructed. - Requires many requests and statistical analysis, but it works.
Where timing attacks are dangerous:
- Password comparison (though hashing makes this moot if bcrypt is used).
- HMAC/signature comparison (
hash_equals()required). - API key comparison.
- CSRF token comparison.
- Any secret comparison.
Defense — constant-time comparison:
hash_equals($a, $b) in PHP always takes the same time regardless of where the strings differ. It compares ALL characters before returning.
Other timing vulnerabilities:
- Username enumeration: "User not found" returns 200ms, "Wrong password" returns 500ms (bcrypt hashing) → attacker knows valid usernames.
- Cache timing: Cache hit is faster than miss → reveals what data exists.
Code Example
<?php
// VULNERABLE — short-circuit comparison
function validateApiKey(string $provided, string $stored): bool
{
return $provided === $stored; // timing-vulnerable!
// Attacker can measure response time to guess $stored char by char
}
// SAFE — constant-time comparison
function validateApiKey(string $provided, string $stored): bool
{
return hash_equals($stored, $provided); // always takes the same time
// Also: hash_equals compares HASH of both, further masking length
}
// REAL EXAMPLES where timing matters:
// 1. Webhook signature verification
function verifyWebhookSignature(Request $request): void
{
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('webhooks.secret'));
$provided = $request->header('X-Signature');
// WRONG
if ($expected !== $provided) abort(401);
// RIGHT
if (!hash_equals($expected, $provided)) abort(401);
}
// 2. API key lookup — verify without leaking timing info
function findByApiKey(string $key): ?User
{
// WRONG: find by key, returns null instantly if not found
// return User::where('api_key', $key)->first();
// BETTER: always do the comparison in constant time
// (Hashing the key makes timing less useful even with === comparison)
$hash = hash('sha256', $key);
return User::where('api_key_hash', $hash)->first();
}
// 3. Password check — bcrypt handles this correctly
// Hash::check() uses a constant-time comparison internally
// Also: bcrypt always takes the same time for ANY input → no timing difference between "user not found" and "wrong password"
// But: still a risk if "user not found" returns early BEFORE hashing
function login(string $email, string $password): ?User
{
$user = User::where('email', $email)->first();
// WRONG: returns early if user not found → timing difference reveals valid emails
if (!$user) return null;
if (!Hash::check($password, $user->password)) return null;
// RIGHT: always run hash_check even if user not found
$hash = $user?->password ?? '$2y$12$invalidhashforfakedummycomp'; // always bcrypt-check
if (!$user || !Hash::check($password, $hash)) return null;
return $user;
}