In Laravel, how does a hand-built login use Auth::attempt, and why does the documented example call $request->session()->regenerate() afterwards?
answer
- returns true or false, never throws
- non-password keys become where clauses
- provider checks the hash, not SQL
- session fixation: rotate the ID
- SessionGuard::login already calls regenerate(true)
basics
~20 sAuth::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
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
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.
Explain the provider lookup that skips password keys, the hash check, the timebox, and what login() writes to the session including the ID rotation.
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.
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.