Back to Feed
article 3m read

The clock and a status code kept revealing which emails had accounts

Signup and login got the same words for everyone, but the same answer still leaked through response time, a lockout status code and a waited-on email step, plus a lowercase mismatch and unthrottled license-key endpoints. None of it is timed yet.

Article DepthPractice

Flow

What changed on the sign-in paths

  1. 1One signup reply

    New and taken addresses get the same words and status.

  2. 2Slow check on every login

    Misses compare against a decoy hash that costs the same.

  3. 3Locks look like wrong passwords

    No special status or note for a locked account.

  4. 4Email starts without waiting

    Mail errors are logged with a masked address.

  5. 5Two rate limits

    License-key requests count per caller and per key.

Asking the signup form was enough to learn whether an email address had an account. Type in a registered address and it said the address was taken. Anyone with a list of addresses could check them all.

Changing the message was easy. Every signup now gets the same reply: if this address isn't registered yet, check your inbox. The status is the same on both paths too. The real owner finds out from their email.

But that only fixed the words. Going back through the sign-in code the same day, I found three more ways it gave the answer away: time, status codes, and waiting on email. Plus two smaller holes.

The stopwatch on login

When an address matched no user, login said no right away. When it matched, login checked the password, and that check is slow on purpose. So "no such user" was fast and "wrong password" was slow. Time enough requests and you can tell which addresses are real.

Accounts with no stored password took the same fast exit. Now both cases run the slow check against a fixed decoy hash before saying no. In made-up code:

const ok = await slowCheck(pw, acct?.hash ?? DECOY)
if (!acct || !acct.hash || !ok) return deny()

The decoy has to cost the same as a real hash, or the gap comes back.

Signup had the same problem in reverse. A taken address got a quick answer, while a new one hashed a password and saved the account. Now the taken path hashes the password too and throws it away. The saving step still differs.

The status code that admitted everything

Too many bad guesses locked an account, and a locked account got its own status code and a friendly note. That tells a stranger the account exists and someone has been guessing at it. Now a locked account gets the same answer as a wrong password. The owner gets no explanation from the form for now.

The email the request waited for

New signups used to wait for the verification email step before answering. Taken addresses didn't. So slow mail gave the answer away. Now signup starts that step and answers without waiting.

My first try at that threw away any mail errors, so broken mail would go unseen. A follow-up the same day logs a masked address and the kind of error instead.

Two smaller holes in the same area

Login used the address as typed, but signup saved it in lowercase. Now login lowercases it first.

The license-key endpoints had no rate limits. Now each request is counted twice: once per caller address, and once per key. That slows down both kinds of sweep. The code that finds the caller's address had been copied three times and had drifted. It's one shared helper now.

The part I took on faith

My first suspect is that saving step on signup. A new account still writes rows a taken address never touches, and I'd guess that gap shows up on a quiet server. None of this has been timed yet.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward