solenya.info

Email enumeration in a password-reset endpoint (200 vs 422 tells you who has an account)

2026-08-11 · medium Large festival crew-accreditation platform (Tier 3)
Target redacted. This finding is not submitted to the program nor publicly disclosed yet, so hostnames are replaced with redacted.example and account identifiers are removed. The technical detail is kept intact.

Summary

POST /api/forgot-password answered differently depending on whether the email existed. That turns an anonymous endpoint into a free "is this person registered?" oracle for the whole crew database.

Impact

Reproduction

1. Get a CSRF token (requires the legitimate Origin):

GET /api/csrf-cookie HTTP/1.1
Host: redacted.example
Origin: https://app.redacted.example

2. Ask for a reset with an existing account:

POST /api/forgot-password HTTP/1.1
Host: redacted.example
Content-Type: application/json
X-XSRF-TOKEN: <token>
Origin: https://app.redacted.example

{"email":"crew@redacted.example"}

3. Compare responses:

Email stateStatusBody
Registered200"We have e-mailed your password reset link!"
Unknown422"We can't find a user with that e-mail address."

Root cause

The controller returns validation errors per-email instead of a single generic answer. Laravel/Fortify makes this easy to do right (sendResetLink result is a status, not a user-existence check).

Remediation

Always return the same 200 + generic message, and rate-limit the endpoint per IP and per email.

Lesson

Differential responses are the tell. Two bodies, one request, no auth: that is a finding.