Timing-Safe String Comparison: Why Your Auth Is Probably Leaking Data
I'll write this post now.
Timing-Safe String Comparison: Why Your Auth Is Probably Leaking Data
Your == operator is a snitch.
Every time your code compares a password hash, API key, or webhook signature using standard string equality, it leaks information. Not a lot. Maybe 5 nanoseconds per character. But in 2024, James Kettle proved that timing differences as small as 200 microseconds are exploitable on live targets -- and he did it against 30,000 real websites on DEF CON's conference WiFi.
The fix is one line of code. Almost nobody uses it.
How String Comparison Leaks Secrets
Here's what happens when you write secret == user_input in virtually any language:
# What Python's == actually does (simplified)
def str_equals(a, b):
if len(a) != len(b):
return False
for i in range(len(a)):
if a[i] != b[i]:
return False # <- returns EARLY
return True
The comparison bails at the first mismatched character. Comparing "apple" with "pecan" returns faster than comparing "apple" with "apricot" because the first pair already differs. That time delta is tiny -- single-digit nanoseconds -- but it's measurable.
An attacker guesses the first character. They send 256 requests, one for each possible byte value. The request that takes slightly longer got the first character right, because the comparison moved on to check the second. Repeat for every position. A 32-character API key takes 8,192 requests instead of 256^32 brute-force attempts.
That's the theory. Here's why it works in practice now.
"Timing Attacks Don't Work Outside the Lab"
That was the conventional wisdom until recently. Network jitter, load balancers, and variable server load all add noise that should drown out nanosecond differences. Three things changed.
HTTP/2 killed network jitter as a defense. A 2020 USENIX paper by Van Goethem et al. showed that HTTP/2 multiplexing lets you pack two requests into a single TCP packet. Instead of measuring absolute response times (noisy), you observe which response arrives first (binary signal). This drops the detectable threshold to roughly 100 nanoseconds from about 40,000 request pairs. Network jitter becomes irrelevant because both requests experience the same jitter.
Statistical methods got better. Kettle's 2024 research reduced the minimum exploitable timing gap from 30,000 microseconds to 200 microseconds -- a 150x improvement. He demonstrated multi-stage timing attacks against remote targets he didn't control, detecting sub-millisecond differentials in under 10 seconds. On conference WiFi. Live on stage.
Nobody's scanning for it. Your SAST tool catches SQL injection. Your DAST scanner finds XSS. Neither one flags if api_key == request.headers['X-API-Key']. Timing vulnerabilities require specialized analysis that most AppSec pipelines don't include. Precli is one of the few static analyzers that flags Python == on HMAC digests. The rest? Silent.
Apache Got This Wrong in 2026
If you think this only affects hand-rolled auth in side projects, consider CVE-2026-33006. Apache HTTP Server 2.4.66 -- the most deployed web server on the planet -- used a non-constant-time comparison in mod_auth_digest. The digest authentication module leaked credential validation timing, allowing attackers to bypass authentication by measuring response times.
Apache. In 2026. Got basic string comparison wrong.
And it gets worse. Python's hmac.compare_digest -- the function specifically designed to prevent timing attacks -- was itself broken until Python 3.9.1 (CVE-2022-48566). CPython's compiler optimizations short-circuited the constant-time comparison loop, reintroducing the exact vulnerability the function existed to prevent.
The Rust ecosystem isn't immune either. The httpsig-rs crate used Vec's default equality operator for HMAC comparison in HTTP signature verification. Rust's memory safety guarantees don't help when the logic itself leaks timing information.
The Fix Is One Line
Every major language ships a constant-time comparison function. Use it anywhere you compare secrets.
Python:
import hmac
# Bad
if token == expected_token:
grant_access()
# Good
if hmac.compare_digest(token, expected_token):
grant_access()
Node.js:
import { timingSafeEqual } from 'crypto';
// Bad
if (token === expectedToken) {
grantAccess();
}
// Good
const a = Buffer.from(token);
const b = Buffer.from(expectedToken);
if (a.length === b.length && timingSafeEqual(a, b)) {
grantAccess();
}
Go:
import "crypto/subtle"
// Bad
if apiKey == expectedKey {
grantAccess()
}
// Good
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(expectedKey)) == 1 {
grantAccess()
}
Rust:
use constant_time_eq::constant_time_eq;
// Bad
if token == expected { grant_access(); }
// Good
if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
grant_access();
}
The Node.js version has a quirk worth noting: timingSafeEqual throws if the buffers have different lengths, and that length check itself can leak information. Always verify length first, and make sure the length comparison doesn't short-circuit your auth flow in a way that reveals whether the key length is correct.
Where This Actually Bites You
If you're using Django, Rails, or Laravel's built-in auth, you're probably fine. These frameworks use constant-time comparison internally. The danger shows up when you step outside the framework:
Webhook signature verification. Every Stripe, GitHub, or Twilio webhook comes with an HMAC signature. If you're verifying it with == instead of a timing-safe function, you're leaking your webhook secret.
# This is how most tutorials show webhook verification
import hashlib, hmac
def verify_webhook(payload, signature, secret):
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
# THIS LINE IS THE BUG
return signature == expected
# Fix: one character change
return hmac.compare_digest(signature, expected)
API key validation. If you're checking API keys against a database value with ==, you're exposing which characters are correct. Hash your API keys (SHA-256 minimum) and compare the hashes with a timing-safe function.
JWT signature verification. If you're implementing JWT verification from scratch instead of using a library (don't do this, but people do), the signature comparison is a timing attack surface.
Password reset tokens. Same pattern. Token comparison with == lets an attacker brute-force the token character by character.
At kief.dev, the Vekt API uses SHA-256 hashed keys with timing-safe comparison for exactly this reason. It's not paranoia when it takes one line of code to prevent.
Constant-Time Code Is Surprisingly Hard to Keep Constant-Time
Using the right function isn't always enough. Compilers are smart and adversarial to constant-time code. They see a loop that always runs the same number of iterations regardless of data, and they "optimize" it into a short-circuit. That's exactly what happened with Python's CVE-2022-48566.
Modern CPUs add another layer. Branch prediction, speculative execution, and cache behavior can all reintroduce timing variation even when your source code looks correct. This is why serious cryptographic libraries often drop to inline assembly for sensitive comparisons -- they need to prevent the compiler from "helping."
For application-level code, using the standard library's timing-safe function is the right call. The crypto teams maintaining those functions are fighting the compiler battle so you don't have to. Just make sure you're on a patched version.
The New Frontier: AI Timing Attacks
Bruce Schneier flagged in February 2026 that side-channel attacks are being applied to LLMs. The efficiency optimizations in language models create data-dependent timing characteristics. By monitoring encrypted traffic between a user and a remote LLM, attackers can infer content based on response timing patterns.
The timing attack concept -- measuring how long a system takes to respond to infer information about its internal state -- is expanding beyond string comparison into entirely new domains.
What To Do Right Now
Open a terminal. Run this:
# Python projects.
grep -rn "== .*token\|== .*secret\|== .*key\|== .*signature" --include="*.py" # Node.js projects.
grep -rn "=== .*token\|=== .*secret\|=== .*key\|=== .*signature" --include="*.js" --include="*.ts" # Go projects.
grep -rn "== .*token\|== .*secret\|== .*key\|== .*signature" --include="*.go"
That's a rough grep, not comprehensive. But it'll find the obvious cases. Replace every hit with the appropriate timing-safe function for your language.
If you want to go deeper, check your dependency tree for known timing vulnerabilities. Vekt scans lockfiles across 12 ecosystems against OSV.dev, which includes MAL-* malicious package advisories and CVEs like the ones mentioned in this post. A 3.7MB Rust binary, 50 free scans per day at kief.dev.
The timing attack surface is getting easier to exploit, not harder. The fix is still one line of code. Use it.