Telling a user their email wasn't found is a free account lookup tool for everyone else
Von Seaworthy · 26. August 2026
Photo by Matt Clark on Unsplash
A password reset endpoint has to behave differently depending on whether the email exists, doesn't it? That reasoning is how you end up with this:
app.post('/api/forgot-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email })
if (!user) {
return res.status(404).json({ error: 'No account found with that email' })
}
await sendPasswordResetEmail(user)
res.json({ message: 'Password reset email sent' })
})
It's a genuinely helpful error message. It's also a free tool for finding out which email addresses have accounts on your site, one request at a time, with no rate limit standing between an attacker and a full list.
A different response is a leak, even if it's just a status code
This pattern is called a user enumeration vulnerability, and the leak isn't limited to an obvious message like "no account found." Any observable difference between the "account exists" path and the "account doesn't exist" path counts: a different HTTP status code, a different response body, a different response time because one path hashes a password and the other doesn't, even a different redirect target. An attacker doesn't need the difference to be dramatic, they need it to be consistent, so they can script a request against a list of a few thousand common email addresses and sort the results into "exists" and "doesn't."
The consequence is bigger than it sounds. A confirmed list of real email addresses tied to your specific product is directly useful for a targeted phishing campaign ("we noticed unusual activity on your [Product] account"), and if your login endpoint has its own version of the same leak, "wrong password" versus "no such user", the confirmed list becomes the input to a credential stuffing run against exactly the accounts known to exist. Enumeration on its own doesn't compromise an account, but it hands an attacker a much shorter, much more targeted list to work from than "every email address on Earth."
Respond identically regardless of what the database found
app.post('/api/forgot-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email })
if (user) {
await sendPasswordResetEmail(user)
}
// Same response, same status code, every time
res.json({ message: 'If an account exists for that email, a reset link has been sent' })
})
The message is deliberately non-committal, and it's genuinely fine from a usability standpoint, most people understand "if an account exists" as a normal, slightly formal way of saying "we sent you an email if you have one," rather than reading it as evasive.
Response time is the part people forget. If sending the email is meaningfully slower than not sending it, and it usually is, the async call to your email provider adds a real delay, an attacker can still distinguish the two paths by timing the response even with an identical message and status code. Awaiting the same amount of work either way closes that gap:
app.post('/api/forgot-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email })
await Promise.all([
user ? sendPasswordResetEmail(user) : Promise.resolve(),
dummyDelay(), // roughly matches the email-sending latency when there's no user
])
res.json({ message: 'If an account exists for that email, a reset link has been sent' })
})
This same principle, respond identically regardless of what the lookup found, applies to signup ("that email is already registered") and to login error messages, both of which are common places the same leak reappears under a different endpoint name.
Why the honest version gets written first
"Tell the user if their email wasn't found" is good, ordinary UX advice, and it's the kind of small usability improvement that's easy to add without thinking about it as a security decision at all, because on a contact form or a newsletter signup, it isn't one. It only becomes a vulnerability on endpoints tied to account existence specifically, and the code for "give a helpful error message" is identical either way. Nothing in the syntax marks a password reset endpoint as different from any other form handler.
Seaworthy flags authentication-adjacent endpoints, password reset, login, and signup, where the response differs based on whether a matching account exists, since a consistent response is what keeps account existence from being queryable one request at a time.
Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.
Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.
Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.