skip to content

In Laravel Fortify, what does Fortify::authenticateUsing replace in the login pipeline, and what must its callback take care of?

level: seniorimportance: should knowfreq 25%

answer

  1. return a user, or null or false
  2. Fortify calls guard->login, not attempt
  3. you check the password yourself
  4. runs twice when two-factor is on
  5. authenticateThrough swaps the whole pipeline

basics

~20 s

Fortify::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 s

By 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
<?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

for a junior

Remember that authenticateUsing lets you decide which user logs in, and that your closure returns the user or null.

for a middle

Explain the pipeline steps, that Fortify calls login() with the returned user, and that the password check becomes your responsibility.

for a senior

Spot the double invocation with two-factor on and the lost timebox, keep the callback pure, and move auditing to Login and Failed listeners.

for a principal

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.