skip to content

A Laravel forgot-password endpoint copied from the documented example reveals which email addresses have accounts - where does it leak, and how do you close it?

level: seniorimportance: should knowfreq 40%

answer

  1. two different status strings
  2. throttled only exists for real users
  3. Timebox pads to 200 ms
  4. synchronous mail outlasts the pad
  5. one message for every outcome

basics

~20 s

The broker returns passwords.user for unknown emails but passwords.sent or passwords.throttled for real ones, and the handler shows different messages. Show one neutral message, move slow mail sending out of the request so the 200 ms timebox can hide it, and rate-limit the route.

solid answer

~40 s

The documented handler flashes `__($status)` as a success for `passwords.sent` and as an error for everything else, so an unknown address shows "We can't find a user with that email address" while a real one shows the sent message. `passwords.throttled` leaks too, because only existing accounts have a token to throttle. Timing is the second channel: `PasswordBroker` wraps `sendResetLink()` in a `Timebox` of 200,000 microseconds (`auth.timebox_duration`), which pads a fast unknown-user answer, but if the notification is sent synchronously and takes longer than that, real accounts still answer slower. The fix is to return the same neutral message whatever the status, make the send fast (for example a queued notification), and put a rate limit on the route. Also restrict trusted hosts, because the default link is built from the request's host.

go deeper

for a junior

Know that the reset form must not say whether an email is registered, and that the documented example shows different messages for found and unknown addresses.

for a middle

Map each broker status to the account state it reveals, including passwords.throttled, and rewrite the handler to show one neutral message.

for a senior

Cover the timing channel: what Timebox pads, why synchronous mail defeats it, and how a queued notification, route rate limits and trusted hosts close the remaining gaps.

for a principal

Judge enumeration across the whole product, registration and login included, and decide where uniform responses are worth the support cost.

## How the documented handler leaks The docs' forgot-password route calls `Password::sendResetLink($request->only('email'))` and then branches: - `passwords.sent` becomes a green status message: "We have emailed your password reset link." - anything else becomes a validation error on the `email` field. The broker returns **different statuses for different account states**: | Account state | Status returned | Default message | |---|---|---| | no user with that email | `passwords.user` | We can't find a user with that email address. | | user exists, recent token | `passwords.throttled` | Please wait before retrying. | | user exists, link sent | `passwords.sent` | We have emailed your password reset link. | On an e-learning site, anyone can therefore paste a list of addresses into the form and learn which ones are enrolled students. `passwords.throttled` is easy to miss: the broker checks the throttle **after** it has found the user, so it can only ever come back for a real account. ## The timing channel Laravel already defends part of this. `PasswordBroker::sendResetLink()` runs inside `Illuminate\Support\Timebox::call()` with a duration of **200,000 microseconds** by default (the manager reads `auth.timebox_duration`). The timebox measures how long the callback took and sleeps for the remainder, so a fast "no such user" answer is padded to about 200 ms. The pad only hides work that finishes **inside** it: 1. The unknown-email path does one query and returns quickly, then sleeps up to 200 ms. 2. The real-account path deletes the old token, hashes and inserts a new one, and calls `sendPasswordResetNotification()`. 3. The stock `ResetPassword` notification is not queued, so with a real mail transport the SMTP or API call happens inside the request. If that takes longer than 200 ms, the real-account path is still measurably slower. So a response-time difference remains unless the slow part leaves the request. ## Closing it - **One message for every outcome.** Ignore the difference between `passwords.sent`, `passwords.user` and `passwords.throttled` and return something like "If that address has an account, a reset link is on its way." - **Keep the send fast.** Override `sendPasswordResetNotification($token)` on the user model to send a queued notification of your own, so the found-user path fits inside the timebox. - **Rate-limit the route.** The broker's `throttle` is per account; add route rate limiting keyed by IP so one client cannot test thousands of addresses. - **Check the whole product.** A registration form that says "email already taken" leaks the same fact; closing only the reset form is theatre if sign-up stays open. ```php $status = Password::sendResetLink($request->only('email')); // Log the real status for support, show everyone the same text. Log::info('reset link requested', ['status' => $status]); return back()->with('status', 'If that address has an account, a reset link is on its way.'); ``` ## The Host header trap The default reset URL is built with `url(route('password.reset', [...], false))`, which takes scheme and host from the current request. If the server answers any `Host` header, an attacker can request a reset for a victim with a forged host and the victim receives a genuine token pointing at the attacker's domain. The docs call out the `trustHosts` middleware method in `bootstrap/app.php` as particularly important for password reset; setting the URL explicitly with `ResetPassword::createUrlUsing()` also removes the dependency on the request. ## A review checklist When reviewing a Laravel forgot-password pull request, walk it in this order: 1. Does the handler branch on `$status` in a way the visitor can see? It should not. 2. Is `passwords.throttled` shown to the visitor? It is an existence signal. 3. Is the notification sent inside the request? If so, measure the found-user path against the 200 ms timebox. 4. Is the route rate-limited per IP or per session, independent of the broker's per-account `throttle`? 5. Is the link host fixed, either by trusted hosts or by `ResetPassword::createUrlUsing()`? 6. Does the matching reset form collapse `passwords.user` and `passwords.token` into one message? ## The reset endpoint leaks too `Password::reset()` looks the user up **before** it checks the token, so an unknown email returns `passwords.user` while a real email with a wrong token returns `passwords.token`. The documented handler shows both messages, so the reset form is a second oracle; map both statuses to one "this link is invalid or has expired" message.

  • Why does passwords.throttled leak account existence even though it looks harmless?
    The broker only reaches the throttle check after the user provider has found an account. An unknown email returns `passwords.user` before any token lookup, so a "please wait" answer proves a real user asked for, or was sent, a link in the last `throttle` seconds.
  • Why does the Timebox not fully hide timing when mail is sent inside the request?
    `Timebox::call()` sleeps only for the remainder of its budget, 200 ms by default. When a synchronous notification takes longer than that, there is no remainder to pad, so the real-account path finishes later than the padded unknown-account path and the difference is measurable.
  • Where does the Host header come into a Laravel password reset?
    The stock `ResetPassword` notification builds its link from the current request's scheme and host. Without trusted hosts, a forged `Host` header on the forgot-password request produces a real token pointing at the attacker's site. Use the `trustHosts` middleware method or build the URL explicitly with `ResetPassword::createUrlUsing()`.

saying these in an interview costs you the question

  • Laravel's broker already returns the same status whether or not the email exists
  • passwords.throttled is safe to show because it says nothing about the account
  • The Timebox makes response times identical however long mail sending takes
  • The broker's throttle setting stops one client probing thousands of addresses
  • Hiding the reset message is enough even if registration says the email is taken