skip to content

In Laravel, how does a hand-built login use Auth::attempt, and why does the documented example call $request->session()->regenerate() afterwards?

level: juniorimportance: must knowfreq 70%

answer

  1. returns true or false, never throws
  2. non-password keys become where clauses
  3. provider checks the hash, not SQL
  4. session fixation: rotate the ID
  5. SessionGuard::login already calls regenerate(true)

basics

~20 s

Auth::attempt($credentials) finds the user by the non-password keys, checks the password hash and, on success, signs them into the session, returning true or false. Regenerating the session ID defeats session fixation; Laravel 13's guard already does it in login().

solid answer

~50 s

`Auth::attempt(['email' => $email, 'password' => $password], $remember)` goes to the default guard, `web` with the `session` driver. Its user provider drops every key containing `password`, turns the rest into `where` clauses (a closure value can add custom conditions), and then compares the plain password against the stored hash with the hasher. On a match it rehashes the password if the hashing settings changed, calls `login()`, and returns `true`; otherwise it fires `Failed` and returns `false`. The whole check runs inside a timebox of about 200 ms so response time does not reveal whether the email exists. The docs follow success with `$request->session()->regenerate()` to prevent **session fixation**, where an attacker plants a known session ID before the victim signs in. In Laravel 13, `SessionGuard::login()` already calls `regenerate(true)`, which issues a new ID, destroys the old record and rotates the CSRF token, so the explicit call is a second, harmless rotation that keeps the controller correct regardless of the guard.

code

php · 28 lines
php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class KioskLoginController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended('/classes');
        }

        return back()->withErrors([
            'email' => 'The provided credentials do not match our records.',
        ])->onlyInput('email');
    }
}

go deeper

for a junior

Know that Auth::attempt takes the credentials array, returns true or false, and that the plain password goes in because Laravel compares it against the hash.

for a middle

Explain the provider lookup that skips password keys, the hash check, the timebox, and what login() writes to the session including the ID rotation.

for a senior

Explain session fixation and why the ID must change at sign-in, and know that Laravel 13's guard already rotates it while keeping the documented regenerate call.

for a principal

Decide when a hand-built login is justified over Fortify, and which review checklist items (fixation, generic errors, throttling, audit events) the team must own.

## The scenario A self-service kiosk application, the kind of touch-screen terminal where members of a gym sign in to book a class, has no starter kit. Its login is a controller you write yourself, and `Auth::attempt` is the core of it. ## What `Auth::attempt` does `Auth::attempt(array $credentials, bool $remember = false)` is a method of the **stateful session guard** (`Illuminate\Auth\SessionGuard`). Called on the facade, it runs on the default guard, which is `web` in a fresh Laravel 13 app. Step by step: 1. It fires the `Attempting` event. 2. It asks the user provider for `retrieveByCredentials($credentials)`. The provider **removes every key containing `password`** and adds a `where` for each remaining key, a `whereIn` for arrays, and for a closure it calls the closure with the query so you can add conditions. 3. If a user came back, the provider's `validateCredentials()` compares the **plain** submitted password with the stored hash using the hasher. You never hash the input yourself. 4. On success it rehashes the password if the hashing configuration changed (`hashing.rehash_on_login`, true by default), calls `login($user, $remember)` and returns `true`. 5. On failure it fires `Failed` and returns `false`. It never throws for bad credentials. The whole call runs inside a **timebox** (`auth.timebox_duration`, 200,000 microseconds by default), so a missing email and a wrong password take about the same time. ## What `login()` does to the session `SessionGuard::login()` is where a successful attempt becomes a signed-in session: - it stores the user ID under a guard-specific key such as `login_web_<sha1>`; - it calls `$session->regenerate(true)`: a **new session ID**, the **old session record destroyed**, and a **new CSRF token**; - it stores an HMAC of the password hash as `password_hash_web`, which `auth.session` later uses to detect password changes; - with `$remember` true, it queues the remember-me cookie; - it fires the `Login` event. ## Why the docs still call `regenerate()` **Session fixation** is an attack where someone gets a victim to use a session ID the attacker already knows, waits for the victim to sign in, and then reuses that ID as an authenticated session. Rotating the ID at the moment of sign-in breaks it. The Laravel docs show `$request->session()->regenerate()` straight after a successful `attempt()`. With Laravel 13's `SessionGuard`, `login()` has already rotated the ID, so the explicit call rotates it once more. Keep it: it is cheap, it matches the documented pattern reviewers look for, and it protects the controller if the guard is ever swapped for one that does not rotate. ## The controller | Situation | What to return | |---|---| | `attempt()` true | `redirect()->intended('/classes')` after regenerating | | `attempt()` false | `back()->withErrors([...])->onlyInput('email')` | | validation fails | `$request->validate()` redirects back automatically | Keep the failure message generic ("credentials do not match") so it does not reveal which half was wrong. Throttling repeated attempts is a separate concern. ## Extra conditions Adding `'active' => 1` to the credentials array narrows the lookup, so an inactive member fails exactly like a wrong password. For a condition that needs the loaded model, `Auth::attemptWhen()` takes callbacks that run after the password has been verified. ## Showing the result in views In Blade, `@auth ... @endauth` renders only for signed-in users and `@guest ... @endguest` only for guests; both accept a guard name, as in `@auth('admin')`, and compile to `auth()->guard(...)->check()` and `->guest()`.

  • How do you add a condition such as 'account not suspended' to Auth::attempt?
    Add it to the credentials array, as in `['email' => $e, 'password' => $p, 'suspended' => false]`; the provider turns every non-password key into a `where`. For something more complex, pass a closure value that receives the query, or use `Auth::attemptWhen()` with a callback that inspects the loaded user after the password is verified.
  • How does a Blade view show a Sign in link only to guests?
    Wrap it in `@guest ... @endguest`, which compiles to `auth()->guard()->guest()`, and wrap the account menu in `@auth ... @endauth`. Both accept a guard name, for example `@auth('admin')`, when the view must check a guard other than the default.

saying these in an interview costs you the question

  • You should hash the submitted password before passing it to Auth::attempt.
  • Auth::attempt throws an exception when the password is wrong.
  • The password is compared inside the SQL WHERE clause.
  • Auth::attempt only checks credentials and never touches the session.
  • A detailed error saying the email does not exist is more helpful and harmless.