Timing attacks in authentication: why constant-time comparison matters
Por Seaworthy · 26 de junio de 2026
What a timing attack actually is
When you compare two strings in most programming languages, the function bails out as soon as it finds the first mismatching character. That is the sensible thing to do for performance. For most strings, it does not matter. For secrets, it hands an attacker a side channel.
If you are comparing a submitted API key against the stored one, a comparison that returns false after 1 microsecond means the first character was wrong. One that returns false after 10 microseconds means the first nine characters matched. An attacker who can make thousands of requests and measure response times can recover your secret one character at a time.
This sounds theoretical. It is not. In 2011, the Keyczar cryptography library had this exact bug in HMAC verification. In 2014, Django patched it. Rails patched it. Express-session has had it. This is a real class of vulnerability, not a CTF curiosity.
Why your === operator is leaking secrets
Here is the naive approach:
function verifyApiKey(submitted, stored) {
return submitted === stored;
}
The === operator in JavaScript (and strcmp in C, and == in most other languages) is implemented to short-circuit. The moment characters diverge, comparison stops. This means response time is a function of how many leading characters match. Given enough samples to average out network jitter, that signal is extractable.
Here is a rough mental model: an attacker submits keys starting with "a", then "b", then "c", measuring which takes slightly longer. The longest response time reveals the first character. Repeat for position two, and so on. For a 32-character hex key, that is at most 32 × 16 = 512 probe batches instead of 16^32 brute-force attempts.
In practice the attack is noisier than this. Network latency swamps nanosecond differences on a remote target. But on same-machine comparisons, or with high-volume probing to average out jitter, it works. And the cost of fixing it is zero, so there is no excuse.
The fix: constant-time comparison
A constant-time comparison function always examines every byte of both strings before returning, regardless of where the first mismatch occurs. It accumulates differences with a bitwise OR, then returns after always doing the same amount of work.
import { timingSafeEqual } from 'crypto';
function verifyApiKey(submitted, stored) {
const submittedBuf = Buffer.from(submitted, 'utf8');
const storedBuf = Buffer.from(stored, 'utf8');
if (submittedBuf.length !== storedBuf.length) {
// Burn the same time before returning false to avoid a length oracle.
timingSafeEqual(storedBuf, storedBuf);
return false;
}
return timingSafeEqual(submittedBuf, storedBuf);
}
Node's crypto.timingSafeEqual is the right call here. Python has hmac.compare_digest. Go has subtle.ConstantTimeCompare. Ruby has ActiveSupport::SecurityUtils.secure_compare. Every serious runtime provides one; use it.
The length check is worth a second look. If you return early when lengths do not match, you have created a length oracle: an attacker learns the expected length of your secret key by checking which submissions get a faster rejection. The pattern above burns the comparison time anyway before returning false.
Where this actually bites you
API key verification is the obvious case, but anywhere you compare a user-controlled value against a stored secret is a candidate:
- HMAC signature verification (webhooks, JWTs with symmetric keys)
- Password reset tokens
- CSRF tokens
- Session identifiers
- Invite codes or one-time tokens
Passwords themselves are usually safe because bcrypt or argon2 comparison takes long enough (hundreds of milliseconds) to drown out the timing signal entirely. But if you are comparing a raw token or hex string, the vulnerable pattern is relevant.
A note on HMAC verification specifically
If you are verifying webhook payloads, the pattern looks like this:
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(payload, secret, receivedSig) {
const expected = createHmac('sha256', secret)
.update(payload)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
const receivedBuf = Buffer.from(receivedSig, 'utf8');
if (expectedBuf.length !== receivedBuf.length) return false;
return timingSafeEqual(expectedBuf, receivedBuf);
}
You will see the insecure version in tutorials constantly. The author computes the HMAC correctly, then compares with === and undoes all the cryptographic work. Stripe, GitHub, Paddle, and every other webhook provider tell you to verify signatures for a reason. Do not hand that protection back to an attacker with a careless comparison.
Seaworthy flags string equality comparisons on secrets and token values as part of its security checks, pointing you to the constant-time alternatives your runtime already provides.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.