Password Reset Scanner
Password reset is often the weakest link in authentication. For every forgot-password endpoint the scanner sends three kinds of request and reads the reply: two different emails to see whether the answer reveals which one exists, five quick repeats to see whether anything pushes back, and one request whose body it searches for the reset token itself.
Email enumeration
An endpoint counts as a reset endpoint when its URL contains /forgot-password, /forgot, /reset-password, /password-reset, /api/auth/forgot, /api/forgot-password or /api/reset-password. The first test posts two different emails one second apart and compares the replies.
1Two POSTs go to the reset endpoint with a JSON body {"email": …}: an address on the reserved .invalid domain that cannot be a user, and admin@test.com, which looks like a real one.
2This endpoint answers differently: 404 with an error for the unknown address, 200 with a confirmation for the other. The scanner records the status and the full body length of each.
3The rule is a plain OR. A different status code is enough. Body lengths are compared as raw character counts and differ only if the gap is more than 20. Here the status differs, so the finding is MEDIUM, confidence 0.80.
4A caveat that follows from counting characters: if both replies are the same safe sentence but echo the submitted address, the two bodies differ by the length of the two addresses (44 against 14, a gap of 30). That is also flagged, so a reply that repeats the email back is worth a second look when this finding appears.
Five rapid requests
The second test asks whether an attacker could flood a victim's inbox. It sends the same reset request five times in a row, with no pause, and counts how many were accepted.
1Five POSTs with {"email": "admin@test.com"}, sent one after another. Only a 200, 201, 202 or 204 counts as an accepted request.
2All five accepted. No 429 and no CAPTCHA step stopped the sequence: MEDIUM, confidence 0.75. An attacker could trigger thousands of reset emails or guess tokens at speed.
3If any reply is 429, the scanner returns at once with no finding: a limiter exists, and the remaining requests are never sent.
4The finding needs exactly five accepted requests. One reply that is neither 2xx nor 429 (a 400, a 500, a CAPTCHA page) makes the count four and the test stays silent, so the check fails closed toward not reporting.
Token in the reply
A reset token proves control of an inbox. It should travel by email only. The third test posts once more and searches the body of the reply with three patterns that each need a token of at least 20 characters.
1One more POST with admin@test.com. The scanner keeps the body and tries each pattern against it. Four replies are shown here for comparison; the scanner sees one per endpoint.
2The patterns look for a JSON field named token or reset_token, or a link field whose URL carries token=. In every case the value must be 20 or more letters, digits, dashes, underscores or dots.
3A plain confirmation matches nothing. A 20-character token in the reply is CRITICAL, confidence 0.95: the attacker who requests the reset can also read the key to it.
4The length floor is the guard against noise: {"token":"abc123"} is too short to be a reset secret and is ignored. A link carrying a long token= value is caught by the third pattern.
On an authenticated deep scan the crawl's session is merged into the one HTTP client this scanner uses (it is auth-aware, #119), so a reset flow behind the login, such as an in-app change-password endpoint, is reachable. The three tests only vary the email, so the session does not change their logic.
Why it matters
Password reset is often the weakest link in authentication. Account takeover: if the reset token is predictable or leaked in the response, attackers can reset any account's password. Account enumeration: different replies for "email found" and "not found" let attackers build a list of valid accounts. Inbox flooding: without rate limiting, attackers trigger thousands of reset emails, causing denial of service for the user and overwhelming the email infrastructure.
The password reset flow allowed enumeration and confirmation of phone numbers linked to accounts, enabling targeted attacks.
Vulnerable vs fixed code
// BAD: the reply depends on the account, and carries the token
const user = await findUser(email)
if (!user) return NextResponse.json({ error: "No such account" }, { status: 404 })
const token = await createResetToken(user)
return NextResponse.json({ message: "Link sent", token })// GOOD: Same response regardless of email validity
return NextResponse.json({ message: "If an account exists, a reset link was sent." })
// Never reveal whether the email exists
// GOOD: Rate limit reset requests
// GOOD: Never return the token in the HTTP response
// Send it via email only:
await sendEmail(email, `Reset: https://app.com/reset?token=${token}`)
return NextResponse.json({ message: "Check your email" })Limits and configuration
| Test | Requests per endpoint | Reports when | Severity · confidence |
|---|---|---|---|
| Email enumeration | 2, one second apart | status codes differ, or body lengths differ by more than 20 characters | Medium · 0.80 |
| No rate limiting | 5, back to back | all five answered 200, 201, 202 or 204; a 429 ends the test | Medium · 0.75 |
| Token in the reply | 1 | a token of 20 or more characters in one of three JSON shapes | Critical · 0.95 |
It runs only at --depth deep, with a 600 s timeout and a time budget checked between endpoints. Every probe is a JSON POST with an email key, so a form-encoded or field-renamed reset request is not exercised. It never sees the email itself, so it cannot tell whether a token that was sent by mail is predictable. The auth bypass scanner also checks reset replies for token-like strings (a looser substring search, HIGH), so one leaking endpoint can appear under both scanners.