17 milliseconds of truth: when your response time answers a question you never asked
Open your login endpoint in a request repeater. Send a username that does not exist, with a wrong password, and note the response time. Send a username that does exist, same wrong password, and note it again.
The status codes match. The bodies match. Invalid credentials, both times, because you took the OWASP advice and made your error messages generic years ago. One of those two requests took four times longer than the other, and that gap is the entire vulnerability.
This is user enumeration through timing, and 2026 produced a strange harvest of it: Traefik, Directus, Kimai, and a small project-management tool all shipped the same class of bug inside the same year, each through a different mechanism. The principle underneath fits in one sentence. Your response time is part of your response, and nobody wrote a test for it.
An old idea that refuses to die
Timing attacks are not new. Paul Kocher described them in 1996, against smartcards, where the attacker more or less holds the device. The comfortable assumption afterwards was that networks are too noisy for this. Jitter, load balancers, garbage collection pauses: surely all that drowns any signal.
Then David Brumley and Dan Boneh extracted an RSA private key from an OpenSSL-based web server across a local network in 2003, and the argument was over. Noise is not a defense, it is a cost multiplier. An attacker with a spreadsheet and a hundred samples per username does statistics until your jitter stops mattering. Account enumeration has been a numbered test in the OWASP Web Security Testing Guide for years, WSTG-IDNT-04, and it carries a whole subsection on analyzing response times because a few hundred milliseconds of extra work, like sending a reset email, is enough to mark a user as real.
The classic: the early return
The most common version of this bug is almost boring. In the usual Express-shaped login handler you look the user up by email. Empty result, you return the generic error immediately. User found, you run bcrypt or argon2 on the submitted password, which is deliberately expensive by design, tens of milliseconds of work on purpose, and then you return the same generic error.
Same error. Different work. The clock keeps the receipts.
A project-management tool called 4ga Boards collected exactly this finding as CVE-2026-41418: requests naming a real account averaged about 74 milliseconds, because bcrypt.compareSync() only runs when the account exists, while requests naming a nonexistent account averaged about 17 milliseconds. A ratio of roughly 4.4 to 1, wide enough to read off a single request. Kimai, a self-hosted time tracker, got the same finding on its API authentication path as CVE-2026-80199: the hasher ran only for real accounts, and that path had no throttling at all, so a username dictionary could run unthrottled.
PortSwigger teaches the technique for free in its Web Security Academy, in a lab where the error messages are perfectly generic and you enumerate usernames anyway. Two details from the official solution are worth internalizing. First, you submit a very long password, around a hundred characters, because more input means more hash time, which amplifies the gap. Second, you rotate the X-Forwarded-For header, because when your rate limiter trusts a client-supplied header, the rate limit is also client-supplied. When the official solution to a training lab is to make the honest path do more work and dodge the throttle, assume every working pentester has this one loaded.
Traefik: the optimization was the leak
The Traefik case is my favorite of the harvest, because the timing channel was manufactured by a performance feature.
Traefik's BasicAuth middleware verifies passwords through Go's singleflight package, which coalesces concurrent duplicate calls so that a hundred simultaneous requests for the same expensive computation share one execution. Good pattern, correct instinct: a costly hash should not run a hundred times for identical input. The deduplication key, though, was built from the submitted password and the stored secret. Every request for a nonexistent username resolves to the same absent secret, so under concurrent load all the fake names collapse into one shared hash. Requests for real usernames carry real, distinct secrets and pay for their own hash, every time. Send the flood, read the clock, and the difference between "that user exists" and "no such user" walks out the door. Sequential requests were cost-equalized, so ordinary testing saw nothing. The fix in Traefik v3.7.13 for CVE-2026-88010 was to put the username into the key, so fake names stop coalescing.
The general lesson matters more than the CVE number. A performance optimization is a behavior change. On an authentication path it is a security behavior change, and it deserves the review a security change gets.
Directus: the fix was the leak
Directus had already thought about timing. Their password-reset flow contains a deliberate stall of roughly 500 milliseconds, added so that existing and non-existing email addresses answer in the same amount of time. Right pattern, right reason.
But the reset_url validation ran after the user lookup. An existing user walked far enough down the path to collect the stall before the validation error threw; a nonexistent user exited earlier. The anti-enumeration defense itself became the half-second signal. That is CVE-2026-26185, fixed in 11.14.1 by reordering the checks so cheap validation fails before any user-dependent work happens.
There is an exchange making the rounds on r/ProgrammerHumor this year, the one where AI says "I fixed it" and the human answers "great, now explain why it broke in the first place." Directus managed the same trick without the AI. The fixer and the breaker shipped in the same release, and the thing that broke was the fix.
What actually closes it
The list is short, and none of it is clever.
- Do the same work on both paths. When the user does not exist, hash the submitted password against a dummy hash with the same cost factor anyway, then return the same generic error. The timing branch disappears because there is no branch.
- Order cheap checks first. Input shape, redirect URLs, token format: validate everything that does not touch the user table before anything that does. Directus fixed their leak with a reorder.
- Make any deliberate delay unconditional. A stall that only runs for real users is not a defense. It is a sign reading "user exists here."
- Treat caching and deduplication on auth paths as security changes. A cache that answers "exists" in two milliseconds and "missing" in twenty is the Traefik bug wearing a hat. Your shiny new index makes the hit path crisp while the miss path stays slow, and the delta just got easier to measure.
- Rate limiting narrows the channel; it does not close it. Kimai had none, which turned a side channel into a firehose, but even a well-throttled endpoint leaks to an attacker who samples slowly. Lockouts are for passwords. Existence should cost silence.
The first item is ninety percent of the value, and it fits in one line.
The librarian pause
Picture an old card catalog, the wooden kind with a hundred drawers and a paper label on each. You ask the librarian about an author. For authors she has never heard of, the answer is instant: no. For authors she knows, she crosses the room, finds the drawer, flips through the cards, comes back. After your tenth question you stop needing her to come back at all. The pause is the answer, and every question sharpens the map.
Your users table is the catalog and your bcrypt is the walk to the drawer. Somewhere right now a script is working through a breach dump, email by email, doing the Tony Stark head turn every time one address answers half a second late.
Check it yourself
Your API returns three things: a status code, a body, and a duration. The first two get reviewed in every pull request. The third ships unreviewed, unsigned, and truthful by accident.
Curl your own login endpoint with one real address and one fake one, a hundred times each, and compare the medians. If you can tell who exists from your couch, so can I, and I bill by the hour.