In Laravel Fortify, what does Fortify::authenticateUsing replace in the login pipeline, and what must its callback take care of?
answer
- return a user, or null or false
- Fortify calls guard->login, not attempt
- you check the password yourself
- runs twice when two-factor is on
- authenticateThrough swaps the whole pipeline
basics
~20 sFortify::authenticateUsing replaces the credential check: instead of guard->attempt(), Fortify calls your closure, which must verify the credentials and return the user or null/false; Fortify then calls login() on the guard. Throttling, two-factor and session regeneration still run around it.
solid answer
~50 sBy default Fortify's `AttemptToAuthenticate` step calls `attempt()` on the configured `StatefulGuard` with the username field and password. `Fortify::authenticateUsing(fn (Request $request) => ...)` replaces that: Fortify calls your closure, and if it returns a user it calls `$guard->login($user, $remember)`; if it returns `null` or `false` Fortify fires the `Failed` event, increments its login limiter and throws a validation error on the username field. Your closure therefore owns everything `attempt()` did: finding the user, checking the password with `Hash::check`, and any extra conditions, such as refusing a tax-filing account that is locked for an audit. Two traps: when two-factor authentication is enabled, `RedirectIfTwoFactorAuthenticatable` calls the same closure first, so it runs twice on a normal login and must have no side effects; and the built-in 200 ms timebox that masks user-exists timing is skipped on this path. To reorder or replace steps, use `Fortify::authenticateThrough`.
code
php · 19 lines<?php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Laravel\Fortify\Fortify;
// In App\Providers\FortifyServiceProvider::boot()
Fortify::authenticateUsing(function (Request $request) {
$user = User::where('taxpayer_ref', $request->taxpayer_ref)->first();
if ($user
&& ! $user->frozen_for_audit
&& Hash::check($request->password, $user->password)) {
return $user;
}
return null; // Fortify fires Failed, bumps the limiter, returns the error
});go deeper
Remember that authenticateUsing lets you decide which user logs in, and that your closure returns the user or null.
Explain the pipeline steps, that Fortify calls login() with the returned user, and that the password check becomes your responsibility.
Spot the double invocation with two-factor on and the lost timebox, keep the callback pure, and move auditing to Login and Failed listeners.
Decide when customising a callback is enough and when a bespoke pipeline, or owning login outright, is the more reviewable choice.
## Fortify's login pipeline A `POST /login` in Fortify is handled by `AuthenticatedSessionController::store`, which sends the request through a **pipeline** of small classes. In Fortify 1.40, with the stub configuration, the default pipeline is: 1. `EnsureLoginIsNotThrottled` - only when `fortify.limiters.login` is empty; otherwise route middleware throttles instead. 2. `CanonicalizeUsername` - when `lowercase_usernames` is true, as in the stub. 3. `RedirectIfTwoFactorAuthenticatable` - when the two-factor feature is enabled. 4. `AttemptToAuthenticate` - checks the credentials and logs the user in. 5. `PrepareAuthenticatedSession` - regenerates the session ID and clears the login limiter. ## What `authenticateUsing` replaces Without a callback, `AttemptToAuthenticate` calls `$guard->attempt($request->only(Fortify::username(), 'password'), $request->boolean('remember'))`. The guard's provider looks the user up and checks the hash. With `Fortify::authenticateUsing($callback)` registered, typically in `FortifyServiceProvider::boot()`: - Fortify calls `$callback($request)`. - A truthy return is treated as the user, and Fortify calls `$guard->login($user, $request->boolean('remember'))`. - A `null` or `false` return makes Fortify fire `Illuminate\Auth\Events\Failed`, increment its `LoginRateLimiter`, and throw a `ValidationException` with the `auth.failed` message on the username field. Steps 1, 2 and 5 still run, and so does the two-factor step, so throttling, session regeneration and the challenge redirect are kept. ## What the callback must do Because Fortify now calls `login()`, which trusts whatever it is given, the callback carries the whole credential decision: - **Look the user up** by whatever identifier your app uses, for example a taxpayer reference instead of an email address. - **Verify the password** with `Hash::check($request->password, $user->password)`. Returning a user without this check logs anyone in. - **Apply extra conditions**, such as refusing an account frozen during an audit or one whose email is unverified, by returning `null`. - **Return the user or nothing**, and never throw for a wrong password, so the failure path (event, limiter, message) stays uniform. - **Expect a canonicalised username.** With `lowercase_usernames` true, as in the stub, `CanonicalizeUsername` runs earlier and lowercases the username field in the request, so a lookup by a case-sensitive identifier must store it lowercased or turn that option off. ## Two traps that come from the source | Trap | Why it happens | Consequence | |---|---|---| | Callback runs twice | With two-factor enabled, `RedirectIfTwoFactorAuthenticatable::validateCredentials` calls the callback, and on a non-2FA user `AttemptToAuthenticate` calls it again | Side effects such as audit rows or counters happen twice | | No timebox | The default path wraps the lookup in `Illuminate\Support\Timebox` (200 ms) in the two-factor step, and `SessionGuard::attempt` has its own; the callback path uses neither | Response time can reveal whether an account exists | Keep the callback **pure**: read, verify, return. Put side effects in a listener for the `Login` or `Failed` events instead. If timing matters, a hash check against a dummy hash when the user is missing keeps the two paths closer. ## When to reach for `authenticateThrough` instead `Fortify::authenticateThrough(fn (Request $request) => [...])` replaces the **entire pipeline** with your own list of classes, each with a `handle($request, $next)` or `__invoke` method. Use it when the change is structural, for example inserting a step that blocks sign-in from sanctioned regions before the password is checked, and use `authenticateUsing` when only the credential check differs. Starting from the documented default list keeps throttling and session regeneration in place. ## The guard Both paths use the guard named by `fortify.guard` (`web` in the stub), which must be a `StatefulGuard`; a token-only guard has no `login()` to call.
- Why should a Fortify authenticateUsing callback not write an audit row?When two-factor authentication is enabled, `RedirectIfTwoFactorAuthenticatable` calls the callback to validate credentials, and for a user without two-factor `AttemptToAuthenticate` calls it again. A write inside it would be recorded twice. Listen for the `Login` and `Failed` events for auditing instead.
- Does throttling still apply when you use authenticateUsing?Yes. Throttling runs before the credential step, either as `EnsureLoginIsNotThrottled` or as the `throttle:login` route middleware, and a failed callback still increments Fortify's `LoginRateLimiter`. The callback replaces only the credential check.
saying these in an interview costs you the question
- Fortify verifies the password even when authenticateUsing returns a user.
- authenticateUsing replaces the whole login pipeline, throttling included.
- The callback should throw an exception when the password is wrong.
- The callback runs exactly once per login attempt in every configuration.
- Any guard, including a token guard, works as fortify.guard.