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 |
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)
Source: Laravel — Remembering Users and HTTP Basic Authentication
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 inusers.remember_tokenis cycled on logout, which invalidates every remember cookie of that user. -
verifiedandpassword.confirmdepend on named routes. Before theverification.noticeandpassword.confirmroutes existed, both middleware failed withRouteNotFoundException(a 500). Fortify registers these routes; an application without Fortify must define them.password.confirmremembers a confirmation for three hours (auth.password_timeout). -
auth.basiclogs the user into the session: after one Basic request, the test was authenticated on thewebguard. For APIs,auth.basic.onceauthenticates the single request without a session. Under PHP-FPM, Apache may need a rewrite rule to pass theAuthorizationheader. -
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 raisingBCRYPT_ROUNDSupgrades 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/passkeyson the client.UserimplementsPasskeyUserwith thePasskeyAuthenticatabletrait. -
Fortify’s own login limiter (
login, five attempts per minute per e-mail and IP) answered the sixth wrong attempt with429instead 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) andlaravel/uiwere replaced by Breeze and Jetstream, and those by the starter kits with Fortify (Laravel 12). -
Auth::attempt()without throttling and withoutsession()->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.