Type an email address into a password reset form. If the app says "no account with that address," it just told you something it did not mean to tell you: it confirmed, or denied, that a specific person has an account. Run that a few thousand times and the reset form has handed over a verified customer list.
The fix sounds trivial. Say the same thing either way. What the fix actually requires is two different internal paths that are indistinguishable from outside, and that is the part a numbered list of steps flattens.

Reading the diagram
The whole thing turns on one question: what can somebody standing outside actually observe?
Steps 1 and 2 are the request and the lookup. Nothing interesting yet. Your server has to know whether the account exists, because it cannot send mail otherwise.
Steps 3 to 6 are the split, drawn as the alt block. These two branches do genuinely different amounts of work. One generates a token, writes its SHA-256 hash, and hands a message to an email provider. The other does nothing at all. Storing the hash rather than the token is the same move a magic link makes, and for the same reason: a leaked database should not be a stack of working reset links.
Step 7 is the red band, and it is the entire point of the diagram. Both branches arrive here and produce a reply that is identical in wording, in status code, and in how long it took. Not similar. Identical. The moment any one of those three differs, the form is an account-checking API.
Timing is the one people fix last. If you send the email inline and then reply, the real-account branch is slower every single time, and the gap is usually large enough to measure over a home internet connection. Hand the mail to a queue and return on a fixed path.
Steps 8 to 10 are the redemption: the raw token comes back, gets hashed, gets compared, and gets marked used before anything else happens.
Step 11 is the step that keeps getting left out.
A reset that does not sweep sessions is half a reset. People reset a password precisely when they suspect somebody else has it. If that somebody already logged in, their session cookie survives the password change unless you explicitly delete it. You changed the lock and left them inside. In scans we see this constantly: a correct, careful, hashed-token reset flow that never touches the session table.
What this changes about how you build it
Rate limit the request endpoint. Identical responses stop an attacker learning anything from one request. They do not stop ten thousand requests. Without a limit, the form is still a way to send mail to any address you like, which is both a spam cannon and a fast route to getting your sending domain flagged.
Expire the token, and make it single use. The link sits in a mailbox afterwards. An hour is a reasonable ceiling for a reset, shorter than the fifteen-minute magic link convention would suggest is necessary only because a reset link is more valuable.
Check the rest of the surface for the same leak. The reset form is the famous one, but signup, login, and any "check availability" endpoint answer the same question. Signup genuinely has to tell people an address is taken, so the answer there is rate limiting rather than silence.
The source
---
title: "Same answer, account or no account"
---
sequenceDiagram
autonumber
participant V as Whoever filled<br/>in the form
participant S as Your server
participant D as Your database
participant E as Email
V->>S: reset the password<br/>for alex@example.com
S->>D: is there an account<br/>for this address?
alt an account exists
D-->>S: yes
S->>D: store the SHA-256 hash<br/>of a fresh token
S->>E: the raw token,<br/>inside a link
else no such account
D-->>S: no
Note right of S: nothing stored,<br/>nothing sent
end
rect rgb(254, 242, 242)
S-->>V: if that address is registered,<br/>we have sent a link
end
Note over V,S: identical on both branches.<br/>the reply reveals nothing.
V->>S: the raw token<br/>comes back
S->>D: hash it and compare.<br/>unused and unexpired?
S->>D: set the new password,<br/>mark the token used
S->>D: delete every other<br/>session for this user
Why should the password reset form say the same thing for an address that is not registered?
Because otherwise the form answers a question you never meant to answer: does this person have an account here? Type in a thousand addresses, keep the ones that come back with a different message, and you have a verified customer list. That list is worth money on its own, and it makes every later phishing attempt more convincing because the attacker already knows where the target has an account.
Is a different response time also a leak?
Yes, and it is the one people miss after fixing the wording. The branch with a real account hashes a token, writes a row and hands a message to an email provider. The branch with no account does none of that. If you reply as soon as each branch finishes, the real-account path is measurably slower and the timing alone answers the question. Queue the mail rather than sending it inline, and reply on a fixed path.
Why store a hash of the reset token instead of the token itself?
So that read access to your database is not the same as a working reset link for every pending request. Store the SHA-256 hash and a database dump holds something that cannot be turned back into a URL. The only usable copy is the one already sitting in the user's inbox, which is a much smaller problem.
Why delete other sessions after a password reset?
Because the usual reason someone resets a password is that they think someone else has it. If the attacker already signed in, their session cookie keeps working after the reset unless you go and invalidate it. Skipping this step means the reset changes the lock and leaves the intruder inside the house.
Does hiding whether the account exists hurt the user experience?
Slightly, and it is worth it. Someone who mistyped their address gets a message saying a link is on the way and no link arrives. Soften that by naming the trade in the message itself, something like: if that address is registered you will get a link within a few minutes, otherwise check the spelling. Signup is the place where you genuinely have to tell people an address is taken, and that is a separate, rate-limited problem.
Check What Your Reset Flow Reveals
Our scanner looks at your deployed app for the things this diagram implies: auth endpoints that answer differently for real and fake addresses, missing rate limits, and reset flows that never invalidate the old session.