timing
Every naïve login handler looks like this:
// ❌ Leaks user existence via response time
const user = await db.users.findByEmail(email);
if (!user) return unauthorized(); // ~5 ms
const ok = await password.verify(password, user.pw_hash); // ~200 ms
if (!ok) return unauthorized();
return signIn(user);An attacker walks a wordlist of emails against your endpoint. The “email exists” branch takes ~205 ms; “email doesn’t exist” takes ~5 ms. That 200 ms delta grades the wordlist without ever guessing a password — your entire user directory leaks.
constantTimeVerify
constantTimeVerify(
input: string | Buffer | Uint8Array,
storedHash: string | null | undefined,
options?: VerifyOptions
): Promise<boolean>Runs the umbrella verify router against a real hash if you have one,
against an internal decoy hash if you don’t:
import { password } from '@exortek/password';
const user = await db.users.findByEmail(email);
const ok = await password.constantTimeVerify(password, user?.pw_hash);
if (!ok) return unauthorized(); // same wall-clock time either way
return signIn(user);The decoy is a scrypt hash of a canonical (never-user-supplied)
password, minted once per process on the first call that lands in
the missing-hash path. Successive missing-hash calls hit the cached
decoy — no repeated 200 ms cost.
Why not just delay the missing branch?
You’ll see this recommendation on old blogs:
if (!user) {
await new Promise(r => setTimeout(r, 200));
return unauthorized();
}Two problems:
- The delay is uniform, verify is not. Argon2 with a hot cache takes 180 ms; with a cold one, 220 ms. An attacker measures the variance, not the mean — a fixed 200 ms delay is trivially distinguishable from the natural jitter of a real verify.
- A delayed reject still ties up a socket. Under sustained enumeration you’re paying wall-clock time and connection budget with nothing to show for it.
constantTimeVerify actually runs the KDF against the decoy — same
CPU shape, same jitter distribution, same socket lifetime.
What it doesn’t do
This helper closes the verify-time side channel. It does not
close upstream side channels — a db.users.findByEmail() that hits a
covering index in one case and a full table scan in another will
still leak. Neither does it defend against user enumeration via
signup (“this email is already registered”) or password reset
(“we sent an email — or would have”).
Full defence requires:
- Constant-time verify (this helper) — login endpoint
- Generic “if this account exists, we’ve sent instructions” — reset flow
- Generic “if you’d like to sign up, we’ve sent a link” — signup + reset unified
What about brute-force?
constantTimeVerify doesn’t rate-limit. Pair it with
@exortek/security’s rate-limit keyed on the
email (not the IP — IP rotation is trivial) with a slow-burn budget
like 5 attempts per 10 minutes:
import { rateLimit } from '@exortek/security';
const bruteforce = rateLimit.sliding({
requests: 5,
window: '10m',
store: rateLimit.stores.memory(),
});
app.post('/auth/login', async (req, res) => {
const rl = await bruteforce.check({ key: `login:${req.body.email}` });
if (!rl.allowed) return res.status(429).end();
const user = await db.users.findByEmail(req.body.email);
const ok = await password.constantTimeVerify(req.body.password, user?.pw_hash);
if (!ok) return res.status(401).end();
return signIn(user);
});Cost
- First missing-hash call in a process: ~200 ms to mint the decoy
- Every subsequent missing-hash call: identical to a real wrong-password verify, ~200 ms
- Real-hash calls: identical to
password.verify— no overhead
Total ~200 ms of CPU per login attempt whether the account exists or not. That’s the whole point — the attacker can’t measure a difference.