Authentication

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with Fortify 1.40 in the React starter kit and Socialite 5.31. The hand-written flows run in the Bookshelf application and are checked by Pest tests; the Fortify flows run in the starter kit from Inertia and Starter Kits. The plain-PHP version of everything here (password hashing, timing-safe checks, remember-me tokens, throttling) is built step by step on Authentication Without a Framework; token authentication for APIs is on API Authentication: Sanctum and Passport.

Guards, providers and the choice of stack

A guard decides how a request is authenticated (session for browsers, a token guard for APIs); a provider loads users (eloquent with App\Models\User, or database). Both are configured in config/auth.php; auth() uses the default web guard and auth('api-key') another. The practical choice is:

Need Use

A web application with registration, reset, 2FA and passkeys

A starter kit, which uses Fortify (headless routes and actions) with your own pages

A custom login flow

The Auth facade directly, as in the next section

A SPA or mobile app on the same domain, or API tokens

Sanctum; full OAuth 2 server: Passport

"Log in with GitHub/Google"

Socialite, or WorkOS AuthKit in the starter kits

Logging in and out by hand

<?php
// app/Http/Controllers/Auth/SessionController.php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

/** Hand-written login and logout, the way Fortify does it underneath. */
class SessionController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
        $key = Str::transliterate(Str::lower($credentials['email']).'|'.$request->ip());

        if (RateLimiter::tooManyAttempts($key, 5)) {
            throw ValidationException::withMessages(['email' => __('auth.throttle', ['seconds' => RateLimiter::availableIn($key), 'minutes' => 1])]);
        }

        if (! Auth::attempt($credentials, remember: $request->boolean('remember'))) {
            RateLimiter::hit($key, 60);
            throw ValidationException::withMessages(['email' => __('auth.failed')]);
        }

        RateLimiter::clear($key);
        $request->session()->regenerate();                          // new session ID after login

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

    public function destroy(Request $request): RedirectResponse
    {
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect('/');
    }
}
<?php
// routes/web.php
use App\Http\Controllers\Auth\SessionController;
use Illuminate\Support\Facades\Route;

Route::post('/login', [SessionController::class, 'store'])->middleware('guest');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth');
Route::get('/login', fn () => 'login form')->name('login');
Route::get('/dashboard', fn () => 'Hello '.auth()->user()->name)->middleware(['auth', 'verified'])->name('dashboard');
Route::get('/settings/danger', fn () => 'confirmed')->middleware(['auth', 'password.confirm']);
Route::get('/basic', fn () => 'basic: '.auth()->user()->email)->middleware('auth.basic');
Route::get('/api/me', fn () => ['id' => auth('api-key')->id()])->middleware('auth:api-key');
Route::get('/email/verify', fn () => 'Please verify your e-mail address.')->middleware('auth')->name('verification.notice');
Route::get('/user/confirm-password', fn () => 'confirm your password')->middleware('auth')->name('password.confirm');

Auth::attempt() looks the user up by every credential except password, verifies the password with Hash::check() (which is constant-time), and logs in; additional conditions such as 'active' => 1 can go in the array, or a closure in attemptWhen(). The throttle key combines the e-mail and the IP, so one attacker cannot lock out a user from every address, and five failures lock that pair for a minute. redirect()->intended() returns the user to the page the auth middleware sent them away from. The tests:

<?php
// tests/Feature/AuthenticationTest.php
use App\Models\User;
use Illuminate\Auth\Events\{Failed, Login, Logout};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\{Event, Hash};

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->phrase = 'correct-horse-battery-staple';
    $this->ada = User::factory()->create(['email' => 'ada@example.com', 'password' => $this->phrase]);
});

it('logs in, regenerates the session and fires events', function () {
    Event::fake([Login::class, Failed::class]);
    $before = session()->getId();

    $this->post('/login', ['email' => 'ada@example.com', 'password' => $this->phrase])
        ->assertRedirect('/dashboard');

    $this->assertAuthenticatedAs($this->ada);
    expect(session()->getId())->not->toBe($before);
    Event::assertDispatched(Login::class, fn (Login $e) => $e->user->is($this->ada) && $e->guard === 'web');
});

it('rejects a wrong password and throttles after five attempts', function () {
    Event::fake([Failed::class]);

    foreach (range(1, 5) as $i) {
        $this->post('/login', ['email' => 'ada@example.com', 'password' => $this->phrase.'?'])->assertSessionHasErrors(['email' => 'These credentials do not match our records.']);
    }
    $this->post('/login', ['email' => 'ada@example.com', 'password' => $this->phrase])
        ->assertSessionHasErrors('email');                                // locked out even with the right password

    $this->assertGuest();
    Event::assertDispatchedTimes(Failed::class, 5);
});

it('sets a remember-me cookie only when asked', function () {
    $response = $this->post('/login', ['email' => 'ada@example.com', 'password' => $this->phrase, 'remember' => '1']);

    $cookie = collect($response->headers->getCookies())->first(fn ($c) => str_starts_with($c->getName(), 'remember_web_'));
    expect($cookie)->not->toBeNull()
        ->and(intdiv($cookie->getExpiresTime() - time() + 60, 86400))->toBe(400)        // days
        ->and($this->ada->fresh()->remember_token)->not->toBeNull();
});

it('redirects unverified users and guests', function () {
    $this->get('/dashboard')->assertRedirect('/login');
    $this->actingAs(User::factory()->unverified()->create())->get('/dashboard')->assertRedirect('/email/verify');
    $this->actingAs($this->ada)->get('/dashboard')->assertOk()->assertSee($this->ada->name);
});

it('asks for the password again before a sensitive page', function () {
    $this->actingAs($this->ada)->get('/settings/danger')->assertRedirect('/user/confirm-password');
    $this->actingAs($this->ada)->withSession(['auth.password_confirmed_at' => time()])->get('/settings/danger')->assertOk();
});

it('accepts HTTP Basic, which also logs into the session', function () {
    $this->withBasicAuth('ada@example.com', $this->phrase)->get('/basic')->assertSee('basic: ada@example.com');
    $this->assertAuthenticatedAs($this->ada, 'web');                   // auth.basic uses the session guard
});

it('challenges a guest with WWW-Authenticate', function () {
    $this->get('/basic')->assertUnauthorized()->assertHeader('WWW-Authenticate', 'Basic');
});

it('authenticates a bearer API key with a stateless guard', function () {
    $this->ada->forceFill(['api_key_hash' => hash('sha256', 'bk_live_demo')])->save();
    $this->withToken('bk_live_demo')->getJson('/api/me')->assertExactJson(['id' => $this->ada->id]);

    app('auth')->forgetGuards();                                       // guards cache the user within one test
    $this->flushHeaders()->withToken('nope')->getJson('/api/me')->assertUnauthorized();
});

it('rehashes the password on login when the cost changes', function () {
    $old = $this->ada->getAuthPassword();
    expect(Hash::info($old)['options']['cost'])->toBe(4);              // BCRYPT_ROUNDS=4 in phpunit.xml

    config(['hashing.bcrypt.rounds' => 6]);
    app('hash')->forgetDrivers();                                     // the hasher was built with the old cost
    $this->post('/login', ['email' => 'ada@example.com', 'password' => $this->phrase]);

    expect(Hash::info($this->ada->fresh()->password)['options']['cost'])->toBe(6);
});

it('logs out and invalidates the session', function () {
    Event::fake([Logout::class]);
    $this->actingAs($this->ada)->post('/logout')->assertRedirect('/');
    $this->assertGuest();
    Event::assertDispatched(Logout::class);
});
$ php artisan test --filter=AuthenticationTest
   PASS  Tests\Feature\AuthenticationTest
  ✓ it logs in, regenerates the session and fires events                 0.25s
  ✓ it rejects a wrong password and throttles after five attempts        1.04s
  ✓ it sets a remember-me cookie only when asked                         0.03s
  ✓ it redirects unverified users and guests                             0.02s
  ✓ it asks for the password again before a sensitive page               0.02s
  ✓ it accepts HTTP Basic, which also logs into the session              0.03s
  ✓ it challenges a guest with WWW-Authenticate                          0.03s
  ✓ it authenticates a bearer API key with a stateless guard             0.02s
  ✓ it rehashes the password on login when the cost changes              0.02s
  ✓ it logs out and invalidates the session                              0.01s

  Tests:    10 passed (48 assertions)

What the first runs of these tests found:

  • Remember-me lasts 400 days, not "forever": the remember_web_* cookie expired after 400 days, the limit browsers now apply to cookie lifetimes. The token in users.remember_token is cycled on logout, which invalidates every remember cookie of that user.

  • verified and password.confirm depend on named routes. Before the verification.notice and password.confirm routes existed, both middleware failed with RouteNotFoundException (a 500). Fortify registers these routes; an application without Fortify must define them. password.confirm remembers a confirmation for three hours (auth.password_timeout).

  • auth.basic logs the user into the session: after one Basic request, the test was authenticated on the web guard. For APIs, auth.basic.once authenticates the single request without a session. Under PHP-FPM, Apache may need a rewrite rule to pass the Authorization header.

  • Guards remember the user for the life of the application instance: in one test, the second request with a bad API key was still authenticated until forgetGuards(). In production every request starts fresh, except under Octane, which resets guards itself.

  • Passwords are rehashed on login when the hashing cost or algorithm changes (rehash_on_login, on by default), so raising BCRYPT_ROUNDS upgrades hashes as users log in. The test had to rebuild the hasher, which had been created with the old cost.

Auth::viaRequest('api-key', fn (Request $request) => ...), registered in AppServiceProvider::boot() with an 'api-key' => ['driver' => 'api-key'] guard, is the shortest custom guard: it receives the request and returns a user or null. Storing only a SHA-256 hash of the key, as here, means a database leak does not leak usable keys; Sanctum does the same for its tokens. Auth::logoutOtherDevices($password) with the auth.session middleware ends a user’s other sessions after a password change.

E-mail verification and password reset

Implementing MustVerifyEmail on User makes registration send a signed verification link and lets the verified middleware redirect unverified users to verification.notice. The link is a signed URL (/email/verify/{id}/{hash}), so it cannot be forged or edited (Routing has signed URLs).

The password broker creates reset tokens, mails them and checks them:

<?php
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\{DB, Hash, Notification, Password};

$ada = User::where('email', 'ada@example.com')->firstOrFail();
$token = null;
Notification::fake();
echo 'send: ', Password::sendResetLink(['email' => 'ada@example.com']), PHP_EOL;
Notification::assertSentTo($ada, ResetPassword::class, function ($n) use (&$token) { $token = $n->token; return true; });
echo 'again within a minute: ', Password::sendResetLink(['email' => 'ada@example.com']), PHP_EOL;
echo 'unknown e-mail: ', Password::sendResetLink(['email' => 'nobody@example.com']), PHP_EOL;
$row = DB::table('password_reset_tokens')->first();
echo 'stored token is a hash: ', var_export(str_starts_with($row->token, '$2y$'), true), ' (token itself ', strlen($token), ' chars)', PHP_EOL;

$new = 'a-new-long-passphrase';
$status = Password::reset(
    ['email' => 'ada@example.com', 'token' => $token, 'password' => $new, 'password_confirmation' => $new],
    function (User $user, string $password) { $user->forceFill(['password' => Hash::make($password)])->save(); },
);
echo 'reset: ', $status, '; reuse: ', Password::reset(['email' => 'ada@example.com', 'token' => $token, 'password' => $new], fn () => null), PHP_EOL;
send: passwords.sent
again within a minute: passwords.throttled
unknown e-mail: passwords.user
stored token is a hash: true (token itself 64 chars)
reset: passwords.reset; reuse: passwords.token

The token is 64 random characters, stored only as a bcrypt hash, valid for 60 minutes and single-use (reuse: passwords.token); a second request within a minute is throttled. The broker returns passwords.user for an unknown address, so a form that shows that message tells an attacker which e-mails have accounts. Show the same "if the address exists, we sent a link" message for both outcomes. php artisan auth:clear-resets removes expired tokens.

Fortify in the starter kits: 2FA and passkeys

The starter kits do not contain controllers for authentication; Fortify registers the routes and runs the logic, and the kit provides the pages and the actions in app/Actions/Fortify. The React kit’s config/fortify.php enables:

<?php
// config/fortify.php (excerpt, React starter kit)
use Laravel\Fortify\Features;

return [
    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
        ]),
        Features::passkeys([
            'confirmPassword' => true,
        ]),
    ],
];
$ php artisan route:list | grep -E 'two-factor|passkeys|login'
  GET|HEAD  login login › Laravel\Fortify › AuthenticatedSessionController@create
  POST      login login.store › Laravel\Fortify › AuthenticatedSessionController@store
  GET|HEAD  two-factor-challenge two-factor.login › Laravel\Fortify › TwoFactorAuthenticatedSessionController@create
  POST      two-factor-challenge two-factor.login.store › Laravel\Fortify › TwoFactorAuthenticatedSessionController@store
  POST      user/confirmed-two-factor-authentication two-factor.confirm › Laravel\Fortify › …
  GET|HEAD  passkeys/login/options passkey.login-options › Laravel\Passkeys › …
  POST      passkeys/login passkey.login › Laravel\Passkeys › PasskeyLoginController
  POST      user/passkeys passkey.store › Laravel\Passkeys › PasskeyRegistrationController
  GET|HEAD  .well-known/passkey-endpoints well-known.passkeys › …

# six wrong logins against the kit's POST /login (Inertia request with the XSRF token)
302 302 302 302 302 429
  • Two-factor authentication stores an encrypted TOTP secret and recovery codes per user; with confirm, the user must enter a code before 2FA becomes active, and login then redirects to /two-factor-challenge. TOTP is covered in general on Authentication Methods, 2FA and Passwordless.

  • Passkeys (WebAuthn) let users log in with Face ID, Touch ID, Windows Hello or a security key; the kit includes the registration and login screens and @laravel/passkeys on the client. User implements PasskeyUser with the PasskeyAuthenticatable trait.

  • Fortify’s own login limiter (login, five attempts per minute per e-mail and IP) answered the sixth wrong attempt with 429 instead of another redirect with errors.

Social login with Socialite

Socialite implements the OAuth 2 authorization-code flow for GitHub, Google, Facebook, LinkedIn, Slack, X, GitLab, Bitbucket and Twitch (more through community providers). The redirect, with a demo client ID in config/services.php:

<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
use Laravel\Socialite\Facades\Socialite;

Route::get('/auth/github', fn () => Socialite::driver('github')->scopes(['read:user'])->redirect());

Route::get('/auth/github/callback', function () {
    $github = Socialite::driver('github')->user();             // exchanges the code, checks the state
    $user = \App\Models\User::updateOrCreate(['github_id' => $github->getId()], [
        'name' => $github->getName() ?? $github->getNickname(),
        'email' => $github->getEmail(),
    ]);
    auth()->login($user, remember: true);

    return redirect('/dashboard');
});
$ curl -s -D - -o /dev/null localhost:8000/auth/github | grep -i '^location'
Location: https://github.com/login/oauth/authorize?client_id=Iv1.demo0000000000&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fgithub%2Fcallback&scope=user%3Aemail%2Cread%3Auser&response_type=code&state=…

The state parameter, stored in the session, is the CSRF protection of the flow: the callback refuses a response whose state does not match. The default user:email scope was kept next to the requested read:user. The callback was not run, since it needs a real GitHub application and network access; Socialite::fake('github', ...) tests it without either. Match accounts on the provider’s ID, not on the e-mail address: e-mail addresses are reassigned, and not every provider verifies them. OAuth itself (PKCE, scopes, tokens) is on Authorization Code and PKCE.

Where older books and tutorials are outdated

  • php artisan make:auth (Laravel 5.x, Stauffer ch. 9) and laravel/ui were replaced by Breeze and Jetstream, and those by the starter kits with Fortify (Laravel 12).

  • Auth::attempt() without throttling and without session()->regenerate() (common in tutorials) allows brute force and session fixation; Fortify does both.

  • Remember-me "for five years" (Laravel 5.x) is capped at 400 days by browsers and by Laravel.

  • bcrypt cost 10 was the old default; new applications use 12 and rehash on login.

  • Passwords plus SMS codes are giving way to TOTP and passkeys, both built into Fortify.