Flow
What changed on the sign-in paths
1One signup reply
New and taken addresses get the same words and status.
2Slow check on every login
Misses compare against a decoy hash that costs the same.
3Locks look like wrong passwords
No special status or note for a locked account.
4Email starts without waiting
Mail errors are logged with a masked address.
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.