October 2, 2026
Constant-Time Doesnβt Mean Constant β The Subtle PHP Timing Leak
Team used hash_equals. Pen-test found 15ms timing leak anyway. Where the signal actually comes from, verified in PHP 8.3.

By Ann R.
17 min read
Here's a security review finding that changed a team's thinking about defense-in-depth.
An authentication service uses hash_equals() for token comparison β team is proud of this, matches best practices. Code review documents the concern about timing attacks; hash_equals is called out as the fix. Compliance auditor sees the function, checks the box.
External security consultant is hired to do a full penetration test. Their report includes a specific finding: the auth service leaks timing information about valid vs invalid tokens, not through hash_equals itself, but through what happens BEFORE and AFTER.
The specific mechanics identified in the report:
- Valid token path:
hash_equalsreturns true β session created β user data loaded β response built β sent to client - Invalid token path:
hash_equalsreturns false β throw AuthenticationException β catch in middleware β log the failure β build error response β send to client
The invalid path throws an exception. Exception construction, stack trace capture, catch handling, and logging add measurable time β MORE than the hash_equals comparison itself.
Consultant demonstrates: with 10,000 measurements over a low-latency network path, they can distinguish valid vs invalid tokens with 99% confidence based on response time. The signal is roughly 15 milliseconds difference β enormous by timing attack standards.
Team's response: "But we use hash_equals!" Consultant's response: "hash_equals fixes one specific vulnerability. It doesn't make your service timing-safe. It handles byte comparison; everything else is still leaky."
The fix requires rethinking the entire auth flow. Uniform code paths for valid and invalid tokens. No exceptions for expected authentication failures. Response padding to bring all responses to a fixed minimum time. Rate limiting on the endpoint. Multiple defenses instead of relying on one function.
The lasting lesson: security functions have specific scopes. hash_equals prevents one specific class of timing leak (byte-level comparison). It doesn't prevent all timing leaks. Real timing safety requires end-to-end uniform execution, not just calling one "safe" function. And even then, the practical goal is usually "make attacks infeasible" rather than "eliminate all signal" β because true constant-time execution in PHP userspace is impossible.
This article walks through timing leaks that persist despite using hash_equals, verified demonstrations of how different code paths leak information via response time, and practical strategies for reducing (not eliminating) timing signal. Verified with PHP 8.3.6: exception construction + catch is 28.93x slower than normal return; different result-building code paths differ by 43.67x; SplStack vs array data structures differ by 4.35x; small vs large object allocation differs by 1.31x. hash_equals handles one specific step; end-to-end timing safety requires attention to the whole flow.
TL;DR Speedrun
hash_equalsis a byte-comparison guarantee, not an end-to-end guarantee. It ensures byte-level comparison timing doesn't leak match position. It doesn't make surrounding code paths timing-uniform. Total request time can still vary hugely based on which branch executed.- Verified: exceptions are 28.93x slower than normal returns. If auth failure throws and success doesn't, response timing directly indicates outcome. Never use exceptions for expected authentication failures in security-sensitive paths.
- Verified: different result-building paths differ by 43.67x. Small success response (2 fields) vs verbose error response (5+ fields, arrays, timestamps) leaks information about which path executed. Even without exceptions, structural differences leak.
- Verified: data structure choice affects timing (SplStack vs Array = 4.35x). Object allocation size affects timing (small vs large object = 1.31x). Regex first-call vs cached call differs measurably. String interning affects comparison timing subtly.
- You cannot eliminate all timing leaks in PHP userspace. JIT compilation, garbage collection, opcache state, database query planning, network scheduling β all introduce variance. Real goal: reduce signal below what's practically exploitable.
- Practical mitigations: uniform code paths (success and failure do similar work); no exceptions for expected auth failures; no early returns; response padding (fixed minimum time); rate limiting (limit sample collection); response jitter (add noise to signal).
- Threat model matters: same-datacenter attackers can measure microseconds; internet attackers face milliseconds of network jitter. Perfect timing safety is impossible; practical timing safety is achievable through defense-in-depth.
What You'll Learn
- Where timing leaks persist despite
hash_equals - Verified magnitude of common leaks
- Structural patterns that leak
- Practical mitigations
- When to worry (and when not to)
Recap: hash_equals Scope
hash_equals provides constant-time comparison for two equal-length strings. Its guarantee: timing doesn't depend on where the first byte difference is. Doesn't short-circuit like ===. This closes one specific vulnerability: byte-by-byte token guessing.
But hash_equals doesn't guarantee:
- Total request time is constant regardless of input
- Code executed BEFORE
hash_equalsis timing-uniform - Code executed AFTER
hash_equalsis timing-uniform - Cache state doesn't leak information
- Memory allocation patterns are uniform
- Exception handling is uniform
Real timing safety requires end-to-end uniform execution. hash_equals is one piece of a larger puzzle. Missing the other pieces means still leaking timing, potentially at MORE bits of signal than hash_equals would leak without protection.
Verified: Different Code Paths Leak Timing
The setup: two functions that could be called after an auth check. One returns a small success response; other returns a verbose error response.
<?php
function processValidUser(string $token): array {
return ['status' => 'ok', 'user_id' => 42];
}
function processInvalidUser(string $token): array {
return [
'status' => 'error',
'error_code' => 'invalid_token',
'error_details' => [
'token_length' => strlen($token),
'attempted_at' => date('c'),
'request_id' => bin2hex(random_bytes(8)),
],
'retry_after' => 60,
];
}<?php
function processValidUser(string $token): array {
return ['status' => 'ok', 'user_id' => 42];
}
function processInvalidUser(string $token): array {
return [
'status' => 'error',
'error_code' => 'invalid_token',
'error_details' => [
'token_length' => strlen($token),
'attempted_at' => date('c'),
'request_id' => bin2hex(random_bytes(8)),
],
'retry_after' => 60,
];
}Verified timing (100,000 iterations):
Valid path (small result): 0.0041s (0.04 Β΅s/call)
Invalid path (larger result): 0.1805s (1.80 Β΅s/call)
Ratio: 43.67x differenceValid path (small result): 0.0041s (0.04 Β΅s/call)
Invalid path (larger result): 0.1805s (1.80 Β΅s/call)
Ratio: 43.67x differenceThe invalid path takes 43x longer. Not because of hash_equals β because building an error response with timestamps, random bytes, and nested arrays takes more work than building a success response with two fields.
Attack scenario: attacker measures response time. Fast response = valid token, slow response = invalid token. Attacker doesn't need hash_equals timing β they get a clearer signal from the response construction difference.
Root cause: asymmetric work between success and failure paths. Common sources:
- Building detailed error responses vs simple success responses
- Additional logging on failures
- Recording failed attempts to a database
- Sending notifications on failures (Slack, email)
- More expensive validation on failures ("why did this fail?")
In practice: many auth systems do MORE work on failure than success. This is precisely backwards for timing safety.
Verified: Exceptions Are Timing Signals
The setup: two paths, identical logic, but one throws-and-catches an exception:
<?php
function normalReturn(): int {
return 42;
}
function exceptionalReturn(): int {
try {
throw new \RuntimeException("test");
} catch (\Throwable $e) {
return 42;
}
}<?php
function normalReturn(): int {
return 42;
}
function exceptionalReturn(): int {
try {
throw new \RuntimeException("test");
} catch (\Throwable $e) {
return 42;
}
}Verified timing (50,000 iterations):
Normal return: 0.0006s (0.01 Β΅s/call)
Exception + catch: 0.0163s (0.33 Β΅s/call)
Ratio: 28.93x slower with exceptionNormal return: 0.0006s (0.01 Β΅s/call)
Exception + catch: 0.0163s (0.33 Β΅s/call)
Ratio: 28.93x slower with exceptionException construction captures stack trace (expensive), catch handling adds overhead. Almost 29x slower than a normal return with the same logic.
Why this matters for auth:
<?php
// Anti-pattern: throw on auth failure
public function verify(string $submitted): User {
$expected = $this->getStoredToken();
if (!hash_equals($expected, $submitted)) {
throw new AuthenticationException('Invalid token'); // 29x slower
}
return $this->loadUser();
}<?php
// Anti-pattern: throw on auth failure
public function verify(string $submitted): User {
$expected = $this->getStoredToken();
if (!hash_equals($expected, $submitted)) {
throw new AuthenticationException('Invalid token'); // 29x slower
}
return $this->loadUser();
}The hash_equals call is timing-safe. The exception throw isn't. Total request time differs enormously between valid and invalid tokens.
Better pattern: use return values, not exceptions, for expected auth failures:
<?php
public function verify(string $submitted): ?User {
$expected = $this->getStoredToken();
if (!hash_equals($expected, $submitted)) {
return null; // fast, uniform with success path timing
}
return $this->loadUser();
}<?php
public function verify(string $submitted): ?User {
$expected = $this->getStoredToken();
if (!hash_equals($expected, $submitted)) {
return null; // fast, uniform with success path timing
}
return $this->loadUser();
}Best pattern: uniform paths that do similar work regardless of outcome:
<?php
public function verify(string $submitted): ?User {
$expected = $this->getStoredToken();
$isValid = hash_equals($expected, $submitted);
// Do similar work regardless
$user = $this->loadUser(); // May not be needed if invalid, but do it anyway
return $isValid ? $user : null;
}<?php
public function verify(string $submitted): ?User {
$expected = $this->getStoredToken();
$isValid = hash_equals($expected, $submitted);
// Do similar work regardless
$user = $this->loadUser(); // May not be needed if invalid, but do it anyway
return $isValid ? $user : null;
}Some wasted work in the invalid path (loading user we won't use), but timing is more uniform. Attacker can't easily distinguish paths.
Trade-offs: uniform execution wastes cycles on unnecessary work. Only worth it for security-critical paths where timing safety matters. Regular application code should use idiomatic patterns.
Data Structure Choice Leaks
Different data structures have different performance characteristics. If success and failure paths use different structures, timing differs.
Verified (10,000 iterations of building and summing 100 items):
Array-based: 0.0131s
SplStack-based: 0.0571s
Difference: 4.35xArray-based: 0.0131s
SplStack-based: 0.0571s
Difference: 4.35xSplStack is 4.35x slower than plain arrays for this pattern. Not surprising β objects vs primitives, method calls vs direct access.
In auth systems: if the valid path uses one data structure and the invalid path uses another (e.g., RoleCollection object for authenticated users, plain array for anonymous), the timing difference leaks user state.
Same for:
- Generator vs array iteration
- SPL data structures vs native arrays
- Object with getters vs direct property access
- Custom collection classes vs primitive types
Mitigation: use consistent data structures across security-critical paths. If success returns an object, failure should too (a "NullUser" or similar). If success returns an array, failure should too.
Memory Allocation Patterns Leak
Different object sizes trigger different allocations, with different timing characteristics.
Verified:
Small object allocation: 0.0034s (34.31 ns/op)
Larger object allocation: 0.0045s (44.98 ns/op)
Ratio: 1.31xSmall object allocation: 0.0034s (34.31 ns/op)
Larger object allocation: 0.0045s (44.98 ns/op)
Ratio: 1.31xSmall object (2 int properties): ~34ns per allocation. Larger object (5 int properties + string + array): ~45ns per allocation. 1.31x difference.
Amplification effect: if a failure path constructs multiple error-related objects (Exception, ExceptionContext, ErrorReport), the allocation differences compound. What starts as 1.31x per object becomes 5β10x cumulative.
Same principle applies to:
- Array size (small arrays vs large arrays)
- String length (allocation is proportional)
- Nested object graphs (deeper = more allocations)
- Circular references (GC involvement)
Mitigation: try to make success and failure paths allocate similarly. If success builds a User object, failure should build a similar-sized "null" or "guest" representation. Avoid asymmetric allocation patterns.
Regex Compilation Cache
PHP caches compiled regular expressions. First use compiles the pattern; subsequent uses reuse the compiled version.
Verified:
First regex call (may compile): 0.001734s
Cached regex avg per call: 0.000000s (immediate/nanosecond scale)First regex call (may compile): 0.001734s
Cached regex avg per call: 0.000000s (immediate/nanosecond scale)First call is measurably slower β compilation happens. Subsequent calls are near-instant (cached).
Timing leak scenario:
- Success path uses regex A
- Failure path uses regex B
- On first request per process, both compile β timing baseline
- On subsequent requests, timing differs based on which regex was used
In practice: minor leak. Both paths' regexes get warm quickly. But it's a real artifact if regex use differs between paths.
Same principle for: file cache (first read vs subsequent), opcache (first execution vs cached), JIT warmup (first hot execution vs subsequent), any cache that varies based on execution history.
Database Prepared Statement Cache
PDO caches prepared statements per connection. First execute: parse SQL, plan query, execute. Subsequent executes: reuse the prepared statement, just execute.
Timing leak scenario:
- Successful login: query "SELECT * FROM users WHERE id = ?" (commonly executed)
- Failed login attempt: query "INSERT INTO failed_login_attempts (ip, timestamp, reason) VALUES (?, ?, ?)" (less commonly executed)
The failed-login query is executed less often, less likely to be in prepared statement cache. Longer to execute. Timing differs.
Also: query plans depend on data. WHERE user_id = ? with a value that exists in the users table takes different time than a value that doesn't β different index paths, different rows examined.
Mitigation: for security-critical paths, ensure both success and failure use pre-warmed queries. Or push the timing-sensitive part earlier (do the DB check first with uniform pattern, then decide what to do).
String Interning
PHP interns some strings (immutable, shared, comparison is pointer comparison). Compile-time literals are typically interned; runtime-built strings usually aren't.
Verified:
Compile-time same string: 10 ns
Dynamically-built strings: 9 nsCompile-time same string: 10 ns
Dynamically-built strings: 9 nsVery small difference β 1 nanosecond. Practically negligible for network-based attacks. But if you're comparing strings built differently in different paths, timing may vary.
Not usually a practical concern. Included for completeness β subtle timing leaks exist at nanosecond scale, but only exploitable in very specific attacker models (physical access, same-CPU-core measurement).
Practical Mitigations
Complete timing safety is impossible in PHP userspace. Practical goal: reduce signal below what's exploitable.
1. Uniform code paths
Ensure success and failure do similar amounts of work. Same allocations, same function calls, similar data structures. Trade-off: some wasted work; benefit: no clear timing signal.
<?php
public function authenticate(string $submitted): ?User {
// Both paths: load user (may or may not use)
$user = $this->userRepo->findById($this->extractClaimedId($submitted));
if ($user === null) {
// Compute a decoy hash to keep timing similar
hash_equals($this->decoyToken(), $submitted);
return null;
}
return hash_equals($user->getToken(), $submitted) ? $user : null;
}<?php
public function authenticate(string $submitted): ?User {
// Both paths: load user (may or may not use)
$user = $this->userRepo->findById($this->extractClaimedId($submitted));
if ($user === null) {
// Compute a decoy hash to keep timing similar
hash_equals($this->decoyToken(), $submitted);
return null;
}
return hash_equals($user->getToken(), $submitted) ? $user : null;
}2. No exceptions for expected failures
Verified: exceptions are 28.93x slower than normal returns. Use return values (null, false, Result object) for expected outcomes. Reserve exceptions for genuinely exceptional errors.
3. Response padding
Delay responses to a fixed minimum time. All responses take at least N milliseconds.
<?php
public function handleAuth(Request $request): Response {
$start = microtime(true);
$result = $this->processAuth($request);
// Pad to minimum 100ms
$elapsed = microtime(true) - $start;
$target = 0.1; // 100ms
if ($elapsed < $target) {
usleep((int)(($target - $elapsed) * 1_000_000));
}
return $result;
}<?php
public function handleAuth(Request $request): Response {
$start = microtime(true);
$result = $this->processAuth($request);
// Pad to minimum 100ms
$elapsed = microtime(true) - $start;
$target = 0.1; // 100ms
if ($elapsed < $target) {
usleep((int)(($target - $elapsed) * 1_000_000));
}
return $result;
}Response always takes β₯100ms regardless of internal path. Attacker can't distinguish fast success from slow failure β both take 100ms.
Trade-off: adds latency to all responses. Only worth it for security-critical endpoints. And attacker can still measure if some responses exceed the target (implying more expensive path).
4. Rate limiting
Even with timing leaks, timing attacks require many measurements to overcome noise. Cap attempts per IP/account/session:
5 attempts per hour per IP
10 attempts per day per account
Lockout after N consecutive failures5 attempts per hour per IP
10 attempts per day per account
Lockout after N consecutive failuresPrevents attacker from collecting enough samples to extract signal. Doesn't hide the leak, but makes exploitation impractical.
5. Response jitter
Add random delay to responses:
<?php
usleep(random_int(0, 100_000)); // 0-100ms random delay<?php
usleep(random_int(0, 100_000)); // 0-100ms random delayAdds noise to timing signal. Attacker needs more samples to extract meaningful information. Effectiveness varies β statistical analysis can defeat jitter if attacker collects enough samples.
6. Shift auth to constant-time primitives
Symmetric crypto for tokens instead of database lookups. Encrypt user ID and metadata into the token; verify on receipt without DB call. Signed tokens (like JWT) allow verification with just crypto operations β fast, uniform timing.
7. Rate limit at the network edge
CDN or load balancer rate limiting reduces load on the application AND limits sample collection. Attacker's requests slowed or blocked before reaching PHP.
When to Worry (and When Not To)
Worry about timing leaks when:
- High-value authentication (banking, financial services, government)
- Same-datacenter attackers (co-tenants, insider threats)
- API endpoints with weak rate limiting
- Long-lived secrets (can accumulate samples over time)
- Predictable secret formats (small keyspace)
Don't obsess when:
- Standard web application over internet with 20β100ms latency
- Strong rate limiting in place
- Short-lived tokens
- Threat model doesn't include timing-sophisticated attackers
- Business value doesn't justify defense cost
Reality check: for most web applications, structural leaks (exceptions, early returns, asymmetric responses) matter more than microsecond leaks. Fix the structural leaks; add rate limiting; move on. Don't spend weeks eliminating nanosecond timing differences that internet attackers can't measure anyway.
Pitfalls to Avoid
Thinking hash_equals is sufficient. It handles one specific vulnerability. End-to-end timing safety requires attention to the whole flow.
Using exceptions for expected auth failures. Verified 28.93x slower than normal returns. Massive timing signal.
Early returns on invalid input. if (!$token) return false; β fast path leaks that token was empty. Even before hash_equals runs.
Building verbose error responses. Detailed error information helps debugging but leaks timing. Consider generic error responses for security-critical failures.
Logging on failure but not success. Log write adds time. Log both or neither, or accept the leak with rate limiting mitigation.
Different data structures in success vs failure paths. Verified 4.35x differences. Choose consistent structures for security paths.
Ignoring database timing. Query time differs based on data, indexes, cache. Auth by DB lookup has variable timing regardless of hash_equals use.
Assuming internet latency masks all leaks. Sophisticated attackers use statistical analysis to extract sub-millisecond signal over noisy networks. Don't rely solely on network jitter.
Skipping rate limiting because "we have hash_equals". Rate limiting is the primary defense against timing attacks. hash_equals closes one specific vulnerability; rate limiting makes exploitation infeasible for many others.
Perfectionism. Trying to make PHP userspace truly constant-time is impossible. Set practical goals; use defense-in-depth; accept some risk.
Not testing timing empirically. Measure your actual auth path with valid and invalid inputs. Look for signal. Fix what you find. Don't rely on theory alone.
Mini Q&A
Does hash_equals actually protect against anything real?
Yes β it closes byte-level comparison timing attacks, which are a real vulnerability class. But it doesn't make your entire application timing-safe. Use it AND design the rest of the flow for timing uniformity.
Are these subtle timing leaks actually exploitable?
Depends on threat model. Over internet with 20β100ms latency: usually not, unless secrets are very small and attacker can gather millions of samples. Over LAN or in same datacenter: yes, sub-millisecond timing is exploitable. Cloud environments with co-tenants: also concerning.
How do I measure my auth path's timing safety?
Load test with valid and invalid inputs. Measure response time distributions. Look for statistically significant differences. Multiple runs to average out noise. If you can distinguish paths, so can an attacker with enough measurements.
Is response padding effective?
Reduces signal but doesn't eliminate it. If some requests exceed the target padding time, attacker still gets signal. Response padding is one layer of defense, not a complete solution. Combine with rate limiting.
Should I use libsodium instead?
libsodium provides constant-time implementations of cryptographic primitives (comparison, verification, key operations). Better than raw PHP for the crypto step. Doesn't help with application-level timing leaks β those are about your code structure, not the crypto library.
Can I use hardware security modules for timing safety?
HSMs move key operations off the main CPU. Their timing characteristics may be more predictable. Adds latency but potentially uniform latency. For very high security applications, worth considering.
What about JIT compilation impact?
PHP 8+ has JIT. First-hot execution may differ from steady-state. Not a major practical concern for auth timing but is another source of variance. If measuring auth timing precisely, warm the JIT first.
Is there a "timing-safe" PHP framework or library?
Not really. Security-focused libraries like paragonie/halite provide constant-time crypto primitives, but application-level timing safety is a design concern, not a library feature. You have to design your code carefully β no library eliminates the need for uniform code paths.
Wrap-Up
Timing safety in PHP is one of those areas where "doing the right thing" (using hash_equals) is necessary but not sufficient. The function handles one specific vulnerability class β byte-level comparison timing. It doesn't address the many other sources of timing variance in a typical application: exceptions, early returns, response construction differences, data structure choices, cache states, database query planning. Real timing safety requires end-to-end attention to uniform execution.
The practical guidance: use hash_equals for byte comparisons (it's still necessary). Beyond that: design auth flows with uniform paths for success and failure. Avoid exceptions for expected authentication failures. Add response padding to hit a minimum fixed time. Implement rate limiting to make sample collection impractical. Accept that perfect timing safety is impossible; aim for "attack is infeasible" rather than "attack signal is zero".
Verified numbers matter here. Different code paths can differ by 43x. Exceptions are 29x slower than normal returns. Data structure choice differs by 4x. Object allocation size affects timing by 1.3x. These aren't theoretical concerns β they're measurable, and in the aggregate, they add up to milliseconds of timing signal that attackers with sufficient sample size can exploit. hash_equals closes the nanosecond-scale byte comparison leak; these structural leaks operate at millisecond scale.
The deeper takeaway: security is defense-in-depth. No single function makes an application secure. hash_equals is important; so is uniform code structure; so is rate limiting; so is threat modeling. Systems that treat security as a checklist of functions accumulate the specific class of "we followed best practices but we're still vulnerable" incidents. Systems that treat security as end-to-end design decisions β with attention to all the ways information can leak β build actual resilience. The engineering discipline of "consider the whole attack surface, not just the recognizable pieces" separates secure applications from applications that appear secure.
Closing Loop
The authentication service team that received the timing leak finding eventually restructures their auth flow. Uniform code paths for success and failure. No exceptions for authentication failures. Response padding to fixed 100ms minimum. Rate limiting at 5 attempts per hour per IP. Multiple layers instead of relying on hash_equals alone.
Three months later, security consultant returns for follow-up testing. Timing signal is now indistinguishable from noise even at high measurement counts. Rate limiting kicks in before enough attempts can accumulate. Response padding masks the internal execution differences. Multiple defenses each contributing to defense-in-depth.
Six months later, when designing a new auth method (WebAuthn), team applies the same principles from the start. Uniform paths, no exception-based control flow for expected outcomes, rate limiting, response padding. Correct by construction rather than requiring fixes later.
A year later, when interviewing senior security candidates, "what timing attacks does hash_equals NOT prevent?" becomes a diagnostic question. Candidates who identify exceptions, early returns, response asymmetry, and cache states demonstrate depth. Candidates who describe hash_equals as "the timing-safe function" reveal they've absorbed the checkbox but not the underlying principle.
The engineering culture generalizes: "security functions solve specific problems, not general security". That principle applies to hash_equals but also to prepared statements (solve SQL injection at query level, not authorization), password_verify (solve password hash comparison, not password strength), TLS (solve transport security, not application security), and any security tool with a specific scope. Systems that respect specific scopes build defense-in-depth; systems that treat single tools as complete solutions accumulate specific vulnerabilities their tools weren't designed to prevent. The specific investment in understanding timing safety generalizes to a broader discipline of security engineering.
"People Also Ask"
- What timing attacks does
hash_equalsNOT prevent? Many.hash_equalsprevents byte-level comparison timing β where === would leak match position through short-circuit timing. It does NOT prevent: (1) different code paths for success vs failure with different execution costs; (2) exceptions thrown for auth failures but not success (verified 28.93x slower); (3) different data structures used in different paths (verified 4.35x for SplStack vs Array); (4) different memory allocation sizes; (5) cache state differences (opcache, regex cache, DB prepared statements); (6) database query timing based on data; (7) network I/O timing. hash_equals is one specific tool; end-to-end timing safety requires attention to the whole flow.
2. Why are exceptions bad for timing safety in PHP? Because exception construction is expensive. Verified: exception + catch is 28.93x slower than normal return with identical logic. Exception construction captures stack trace, catch handling adds overhead. If auth failure throws an exception and success returns normally, response time directly indicates outcome. Attacker measures response time; distinguishes valid from invalid tokens. Use return values (null, false, Result object) for expected authentication failures. Reserve exceptions for genuinely exceptional errors.
3. How much timing signal is exploitable in practice? Depends on threat model. Over internet with 20β100ms latency and jitter: microsecond signal is very hard to extract but not impossible with many samples. Over LAN or same datacenter: sub-millisecond signal is exploitable. Cloud environments with co-tenants: also concerning. Structural leaks measured in milliseconds (exceptions, different code paths) are exploitable over most networks. Sub-microsecond leaks (byte comparison, string interning) rarely exploitable over internet but possible in specific scenarios.
4. What is response padding for timing safety? Delaying responses to a fixed minimum time regardless of internal execution. Pattern: measure elapsed time; if less than target, usleep() to reach target. Example: all auth responses take at least 100ms. Attacker can't distinguish fast success from slow failure β both take at least 100ms. Trade-off: adds latency to all responses. Attacker can still detect if some responses exceed target (implying more expensive internal path), so combine with other mitigations. Effective as one layer of defense-in-depth.
5. Should I use exceptions in my authentication code? For authentication FAILURES: no. Use return values (null, false, Result object). Failed auth is expected behavior, not exceptional. Verified: exceptions are 28.93x slower than normal returns β huge timing signal. For genuinely exceptional errors during auth (database unavailable, corrupted data, missing configuration): yes, exceptions are appropriate. The distinction: expected failure paths use return values; unexpected error paths use exceptions.
6. Does rate limiting prevent timing attacks? Rate limiting is one of the most effective mitigations. Timing attacks require MANY measurements to extract signal from noise. Rate limiting caps attempts per IP/account/time, preventing sample collection. Even with timing leaks, attacker can't gather enough data to exploit them. Standard patterns: 5 attempts per hour per IP; 10 per day per account; lockout after N consecutive failures. Combined with hash_equals and uniform code paths, makes timing attacks impractical for most applications.
7. Can I make my PHP code truly constant-time? No, not in userspace. JIT compilation, garbage collection, opcache state, memory allocator behavior, page cache, network scheduling β all introduce variance PHP code cannot control. Practical goal: reduce signal below what's exploitable given threat model. Use defense-in-depth: hash_equals for byte comparisons, uniform code paths, no exceptions for expected failures, response padding, rate limiting. Aim for "attack is infeasible" rather than "signal is zero".
8. How do I test if my auth code has timing leaks? Load test with valid and invalid inputs. Measure response time distributions. Statistical analysis: compare distributions with tools like t-test or Kolmogorov-Smirnov. Look for statistically significant differences. Multiple runs to average noise. If you can distinguish paths with reasonable sample sizes, an attacker with more samples can too. Also: measure BEFORE and AFTER mitigations. Verify the mitigations actually reduce signal. Don't rely on theory β measure empirically.
Note: All PHP behaviors and timing measurements in this article were verified against PHP 8.3.6. Specific ratios (28.93x for exceptions, 43.67x for different code paths, 4.35x for data structures, 1.31x for object allocation) reflect the test system β actual numbers vary based on hardware, PHP version, OS scheduler, and workload. The relative pattern is consistent: structural code differences leak far more timing than the byte-level differences hash_equals addresses. Perfect timing safety in PHP is impossible; practical timing safety through defense-in-depth is achievable. For applications with strict timing security requirements (high-value authentication, cryptographic services), consider specialized crypto libraries (libsodium), hardware security modules, and threat modeling with security specialists. Measure your specific application's timing profile β theoretical guarantees don't substitute for empirical validation.