Guards define HOW auth state is stored (session for web, token for API)
Providers define WHERE users are fetched from (Eloquent model, raw DB, LDAP)
Drivers handle the mechanical work (reading cookies, hashing passwords, regenerating sessions)
Auth::attempt($credentials) — looks up user, verifies password via Hash::check(), writes to session
Auth::guard('api') — switches to a different guard with independent auth state
$request->session()->regenerate() — prevents session fixation after login
middleware('auth') — protects routes, redirects guests to /login
Plain-English First
Think of a concert venue. The gate staff (guards) check your ticket (credentials) against a list held by the box office (providers). If you're on the list, you get a wristband (session/token) that lets you move freely inside. Laravel's authentication system is exactly that — guards are the bouncers, providers are the guest list, and the session or token is your wristband. You set the rules once; Laravel enforces them everywhere.
Every web application that stores user data needs a trustworthy front door. Without solid authentication, you're essentially leaving the concert venue unlocked and hoping nobody walks in. Laravel ships with one of the most mature, flexible authentication systems in any PHP framework — which is why it's the go-to choice for teams building SaaS platforms, admin dashboards, and APIs that need both security and speed.
Authentication is not just login and logout. It includes session fixation prevention, rate limiting, user enumeration protection, password hashing configuration, multi-guard isolation, and token lifecycle management. Each of these has a failure mode that can be exploited in production.
The most common production auth failures are not sophisticated attacks — they are configuration oversights: missing session regeneration, incorrect guard references, exposed API tokens, and weak password hashing rounds. Understanding the internals of Laravel's auth system is the difference between a secure application and a breach waiting to happen.
How Laravel's Auth System Is Actually Wired Together
Before you touch a single line of code, it's worth understanding the three moving parts Laravel uses behind the scenes: guards, providers, and drivers.
A guard defines how users are authenticated for a given request. The default guard is called web and uses sessions — perfect for browser-based apps. The second built-in guard is api, which uses tokens instead of sessions, suitable for stateless REST APIs.
A provider defines where user data lives. By default it's eloquent, meaning Laravel looks up users via your User Eloquent model. You can swap this for database (raw query builder) or write a completely custom provider for LDAP, an external OAuth service, or any data source you control.
The driver sits inside the guard and handles the mechanical work — reading the session cookie, hashing the password comparison, regenerating the session after login. You rarely touch the driver directly, but knowing it exists helps you understand why two guards can behave completely differently even when they share the same provider.
All of this is wired in config/auth.php. Open it up before you do anything else — understanding that file is 80% of mastering Laravel auth.
Custom guard example: A common production pattern is an 'admin' guard that authenticates admin users against a separate model or table. This ensures admin authentication is completely isolated from customer authentication — separate session data, separate middleware, separate redirects on failure.
config/auth.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php
return [
/*
|--------------------------------------------------------------------------
| DefaultAuthenticationGuard
|--------------------------------------------------------------------------
| This is the guard Laravel uses when you call Auth::check() or
| auth()->user() without specifying which guard to use.
| For a classic web app, 'web' is correct.
| For a pure API, change this to 'api'.
*/
'defaults' => [
'guard' => 'web', // <-- the active guard for browser sessions
'passwords' => 'users', // <-- which password-reset broker to use
],
/*
|--------------------------------------------------------------------------
| Guards
|--------------------------------------------------------------------------
| Each guard pairs a *driver* (how auth state is stored) with a
| *provider* (where user records live).
*/
'guards' => [
'web' => [
'driver' => 'session', // stores auth state in the PHP session
'provider' => 'users', // uses the 'users' provider below
],
'api' => [
'driver' => 'token', // reads a Bearer token from the request'provider' => 'users',
'hash' => false, // set to true if you hash API tokens in DB
],
// Custom admin guard — completely isolated from the 'web' guard'admin' => [
'driver' => 'session',
'provider' => 'admins', // separate provider for admin users
],
],
/*
|--------------------------------------------------------------------------
| UserProviders
|--------------------------------------------------------------------------
| Providers tell Laravel *how* to retrieve users from storage.
| 'eloquent' uses your model class; 'database' uses raw table queries.
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class, // must implement Authenticatable
],
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class, // separate admin model
],
],
/*
|--------------------------------------------------------------------------
| PasswordResetConfiguration
|--------------------------------------------------------------------------
| Controls the token expiry (in minutes) and the table that stores tokens.
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens', // Laravel 10+ renamed this table
'expire' => 60, // token expires after 60 minutes
'throttle' => 60, // can only request a new token every 60 seconds
],
],
'password_timeout' => 10800, // re-confirm password after 3 hours (in seconds)
];
Output
// No terminal output — this is a config file.
// When you run: php artisan config:show auth
// Laravel prints the resolved config array so you can verify your changes.
Guards, Providers, and Drivers as a Security Checkpoint
Separate auth state: admin users and customer users have completely independent sessions.
Separate redirects: failed admin auth redirects to /admin/login, not /login.
Separate middleware: admin routes use middleware('auth:admin'), customer routes use middleware('auth').
Separate providers: admins can live in a different table or even a different database.
Production Insight
Every Auth facade call traces back to config/auth.php. If auth behaves unexpectedly, this is always the first place to look. The most common multi-guard bug: calling Auth::user() (which uses the default guard) when the user authenticated via a different guard. Always specify the guard explicitly when working with multiple guards: Auth::guard('admin')->user().
Key Takeaway
Guards define HOW auth state is stored (session vs token). Providers define WHERE users are fetched from (Eloquent model vs raw table). They are independent — mix and match in config/auth.php. Always behaves unexpectedly.
Guard and Provider Selection
IfBrowser-based web app with session cookies
→
UseUse the 'web' guard with 'session' driver and 'eloquent' provider. This is the default.
IfStateless REST API with Bearer tokens
→
UseUse Laravel Sanctum (auth:sanctum middleware) for first-party APIs. Use Passport for third-party OAuth.
IfAdmin panel with separate admin users
→
UseDefine a custom 'admin' guard with a separate 'admins' provider pointing to an Admin model.
IfLegacy database with non-standard schema
→
UseWrite a custom UserProvider that queries your legacy table. Bind it in AuthServiceProvider.
Laravel Breeze vs Jetstream — Choosing the Right Starter Kit
Laravel ships with two official authentication starter kits: Breeze and Jetstream. Choosing the wrong one is one of the most common and painful early mistakes teams make.
Breeze is minimal by design. It gives you registration, login, logout, email verification, and password reset — all scaffolded with clean Blade templates (or optionally Inertia/React/Vue). The generated code is readable, un-opinionated, and easy to customise. If you're building something custom or learning, start here.
Jetstream is a full-featured auth layer built on top of Livewire or Inertia. It adds two-factor authentication open config/auth.php first when auth, session management (view and revoke active sessions), API tokens via Laravel Sanctum, and team management. It's powerful, but it generates a significant amount of code and is harder to unpick if its opinions clash with yours.
The rule of thumb: if your project needs 2FA, multi-tenancy, or API token management from day one, reach for Jetstream. For everything else, Breeze keeps you in control.
Both kits are installed via Composer and then run with artisan publish commands — they write routes, controllers, views, and tests into your project so you own the code outright.
Migration path: Start with Breeze. If you later need 2FA, add it manually with the Google2FA package or Fortify. If you later need API tokens, add Sanctum. This incremental approach keeps you in control of every line of code. Starting with Jetstream and then trying to remove features is significantly harder.
io/thecodeforge/starter-kit-installation.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# --- InstallLaravelBreeze ---
# Step1: Require the package (development dependency only)
composer require laravel/breeze --dev
# Step2: Scaffold the auth views and controllers.
# Options: blade | react | vue | api | livewire | livewire-functional
php artisan breeze:install blade
# Step3: Run migrations — creates users, sessions, password_reset_tokens tables
php artisan migrate
# Step4: Compile front-end assets (Breeze uses TailwindCSS)
npm install && npm run dev
# --- WhatBreeze actually generates ---
# Routes: routes/auth.php (included automatically in routes/web.php)
# Controllers: app/Http/Controllers/Auth/ (8 focused controllers)
# Views: resources/views/auth/ (login, register, forgot-password, etc.)
# Middleware: already wired — 'auth' middleware redirects guests to /login
# 'guest' middleware redirects authenticated users to /dashboard
# Verify everything is wired by listing all registered routes:
php artisan route:list --path=auth
Output
GET|HEAD / ........................................................ home
POST email/verification-notification ... verification.send
Starter Kits as Prefab Houses
Breeze generates readable, un-opinionated code. You understand every line.
Jetstream generates a lot of code with Fortify actions that are hard to override.
You can add 2FA, Sanctum, and team management to Breeze incrementally.
Removing features from Jetstream is harder than adding features to Breeze.
Production Insight
Never install Jetstream on an existing project that already has custom auth logic. Jetstream's migrations and models will conflict with your existing schema. Use Breeze first — you can always layer Sanctum or 2FA manually later, which keeps you in full control of your schema.
Key Takeaway
Breeze is minimal and readable — start here for most projects. Jetstream adds 2FA, teams, and API tokens but generates opinionated code. Start with Breeze and add features incrementally. Never install Jetstream on an existing project with custom auth.
Building a Custom Login Flow From Scratch (and Why It Teaches You Everything)
Starter kits are great until they aren't. The moment you need to authenticate against a legacy users table with a non-standard password column, or throttle login attempts per-tenant, or return a JSON error instead of a redirect — you need to understand the raw Auth API.
The core method is Auth::attempt(). It accepts an array of credentials, looks up the user via the provider, verifies the password using Hash::check() internally, and — if successful — logs the user in by storing their identifier in the session. It returns true on success and false on failure. That's it.
Auth::login($user) skips credential checking entirely — useful when you've already verified identity another way (OAuth callback, magic link, SSO). Auth::loginUsingId(5) does the same with just a primary key.
For APIs, you'll use Auth::guard('api')->attempt() or, more commonly today, Laravel Sanctum's token issuance — but the underlying pattern is identical.
The example below builds a fully custom login controller: rate limiting, specific error messages suppressed (to prevent user enumeration), and a post-login redirect that respects where the user was trying to go before they were kicked to the login page.
User enumeration prevention: The login response must never distinguish between 'user not found' and 'wrong password'. Both must return the same generic message: 'These credentials do not match our records.' Returning 'user not found' tells attackers which emails are registered in your system.
<?php
namespaceApp\Http\Controllers\Auth;
useApp\Http\Controllers\Controller;
useIlluminate\Http\Request;
useIlluminate\Http\RedirectResponse;
useIlluminate\Support\Facades\Auth;
useIlluminate\Support\Facades\RateLimiter;
useIlluminate\Support\Str;
useIlluminate\Validation\ValidationException;
classCustomLoginControllerextendsController
{
/**
* Show the login form.
* The'guest'middleware (defined in Kernel.php) ensures
* already-authenticated users are bounced to /dashboard
* before they even reach this method.
*/
publicfunctionshowLoginForm()
{
returnview('auth.login');
}
/**
* Handle a login attempt.
* We validate, rate-limit, attempt auth, then redirect.
*/
publicfunctionlogin(Request $request): RedirectResponse
{
// 1. Validate the incoming fields before we touch the database
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
// 2. Build a per-user, per-IP rate limit key.// Using BOTH email and IP makes it much harder to brute-force// from rotating IPs or enumerate valid emails.
$rateLimitKey = Str::lower($request->input('email')) . '|' . $request->ip();
// 3. Reject the attempt if they've failed 5 times in the last 60 secondsif (RateLimiter::tooManyAttempts($rateLimitKey, maxAttempts: 5)) {
$secondsRemaining = RateLimiter::availableIn($rateLimitKey);
// Throw a ValidationException — Laravel formats this nicely// in both Blade views and JSON responses automaticallythrowValidationException::withMessages([
'email' => __('auth.throttle', ['seconds' => $secondsRemaining]),
]);
}
// 4. Auth::attempt() does three things in one call:// a) Looks up the user by email via the Eloquent provider// b) Runs Hash::check($password, $user->password)// c) If both match: writes the user ID to the session and returns true//// The second argument (boolean) controls "remember me" —// true creates a persistent cookie valid for years (configurable).
$rememberMe = $request->boolean('remember');
if (Auth::attempt($credentials, $rememberMe)) {
// 5. Regenerate the session ID to prevent session fixation attacks.// This is critical — always do this after a successful login.
$request->session()->regenerate();
// 6. Clear the rate limit hit counter on successful loginRateLimiter::clear($rateLimitKey);
// 7. intended() redirects to the URL the user was trying to reach// before the 'auth' middleware kicked them to /login.// If there's no intended URL, it falls back to /dashboard.returnredirect()->intended(route('dashboard'));
}
// 8. Failed attempt — increment the rate limiter counterRateLimiter::hit($rateLimitKey, decay: 60); // 60 second decay window// 9. Return a generic error message.// NEVER say 'wrong password' or 'user not found' separately —// that tells attackers which emails are registered (user enumeration).throwValidationException::withMessages([
'email' => __('auth.failed'), // "These credentials do not match our records."
]);
}
/**
* Log the user out.
* Three steps: logout from Auth, invalidate the session, regenerate the CSRF token.
* Skipping any one of these leaves a security gap.
*/
publicfunctionlogout(Request $request): RedirectResponse
{
Auth::logout(); // clears the auth data from the session
$request->session()->invalidate(); // destroy the session entirely
$request->session()->regenerateToken(); // issue a new CSRF tokenreturnredirect()->route('login');
}
}
Output
// Successful login:
// HTTP 302 → /dashboard (or the originally intended URL)
// Session cookie is re-issued with a new session ID
// Failed login (wrong credentials):
// HTTP 422 (Unprocessable Content)
// {
// "message": "These credentials do not match our records.",
// "errors": {
// "email": ["These credentials do not match our records."]
// }
// }
// Rate-limited (6th attempt within 60 seconds):
// HTTP 422
// {
// "message": "Too many login attempts. Please try again in 45 seconds.",
// "errors": { "email": ["Too many login attempts. Please try again in 45 seconds."] }
Email+IP rate limiting solves both: each email+IP combination gets its own limit.
An attacker with rotating IPs can still try 5 passwords per IP per email — but cannot brute-force a single account.
Production Insight
Always call $request->session()->regenerate() immediately after a successful login. If you skip this, an attacker who plants a session ID cookie before login (session fixation) inherits the authenticated session. Laravel Breeze does this automatically — your custom code must too.
Key Takeaway
Auth::attempt() is three steps: look up user, Hash::check() password, write to session. Always regenerate the session ID after login. Always rate-limit with email+IP key. Always return a generic error message to prevent user enumeration. Never distinguish between 'user not found' and 'wrong password'.
Protecting Routes and Checking Auth State in Your Application
Authentication is useless unless you enforce it consistently. Laravel gives you three layers to work with: middleware on routes, policy-level checks in controllers, and inline checks in Blade templates.
The auth middleware is the most important. Apply it to any route or route group that requires a logged-in user. Unauthenticated requests are automatically redirected to the named route login — that name is hardcoded in the Authenticate middleware class, so make sure your login route is actually named login.
For multi-guard applications (say, a web panel and an API living in the same app), you pass the guard name as a parameter: auth:api or auth:admin. Each guard maintains completely independent auth state — a user logged into web is not automatically logged into api.
For finer control inside a controller or service class, the Auth facade exposes a clean boolean API: Auth::check() returns true if the user is authenticated, Auth::guest() is its inverse, and Auth::user() returns the full Eloquent model (or null for guests). In Blade, the @auth and @guest directives keep templates clean without embedding raw PHP.
Middleware stacking order matters: When you apply multiple middleware to a route, they execute in order. Place auth middleware before any middleware that calls Auth::user(). If a middleware that depends on auth state runs before the auth middleware, Auth::user() will be null.
<?php
// ============================================================// FILE 1: routes/web.php — protecting routes with middleware// ============================================================useIlluminate\Support\Facades\Route;
useApp\Http\Controllers\DashboardController;
useApp\Http\Controllers\ProfileController;
useApp\Http\Controllers\Admin\AdminDashboardController;
// Public route — no middleware, anyone can accessRoute::get('/', fn() => view('welcome'))->name('home');
// Single protected routeRoute::get('/dashboard', [DashboardController::class, 'index'])
->middleware('auth') // redirects guests to /login
->name('dashboard');
// Group of protected routes — cleaner than repeating middlewareRoute::middleware(['auth', 'verified'])->group(function () {
// 'verified' means the user must also have confirmed their emailRoute::get('/profile', [ProfileController::class, 'show'])->name('profile.show');
Route::put('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
// Multi-guard example: admin area uses a separate 'admin' guard// This guard would be defined in config/auth.php with its own providerRoute::middleware('auth:admin')->prefix('admin')->group(function () {
Route::get('/dashboard', [AdminDashboardController::class, 'index'])
->name('admin.dashboard');
});
// ============================================================// FILE 2: app/Http/Controllers/DashboardController.php// Checking auth state inside a controller// ============================================================namespaceApp\Http\Controllers;
useIlluminate\Http\Request;
useIlluminate\Support\Facades\Auth;
classDashboardControllerextendsController
{
publicfunctionindex(Request $request)
{
// Auth::user() returns the currently authenticated User model.// Since this route is behind 'auth' middleware, we know it's never null here.// But in code that might run for guests too, always null-check first.
$currentUser = Auth::user();
// auth()->id() is shorthand for Auth::id() — returns the primary key only.// Use this when you just need the ID for a query (avoids loading the full model).
$userId = auth()->id();
// You can also pull the user off the Request object.// Useful in Form Requests and Middleware where $request is already injected.
$userFromRequest = $request->user();
returnview('dashboard', [
'user' => $currentUser,
'recentPosts' => $currentUser->posts()->latest()->take(5)->get(),
]);
}
}
// ============================================================// FILE 3: resources/views/layouts/navigation.blade.php// Conditional rendering based on auth state in Blade// ============================================================
/*
<nav>
{{-- @auth renders its contents ONLYfor authenticated users --}}
@auth
<span>Welcome, {{ Auth::user()->name }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">LogOut</button>
</form>
@endauth
{{-- @guest renders its contents ONLYfor unauthenticated visitors --}}
@guest
<a href="{{ route('login') }}">LogIn</a>
<a href="{{ route('register') }}">Register</a>
@endguest
{{-- Multi-guard check in Blade: check a specific guard --}}
@auth('admin')
<a href="{{ route('admin.dashboard') }}">AdminPanel</a>
@endauth
</nav>
*/
Output
// Unauthenticated request to GET /dashboard:
// HTTP 302 → /login
// (After login, redirect()->intended() sends them back to /dashboard)
// Request to GET /dashboard with email unverified + 'verified' middleware:
// HTTP 302 → /email/verify (the verification.notice named route)
Middleware as Checkpoints on a Path
They return the same user model in most cases.
$request->user() respects the guard resolved for the current request — useful in multi-guard apps.
Auth::user() always uses the default guard unless you specify: Auth::guard('api')->user().
In Form Requests and middleware, prefer $request->user() because the request already knows which guard was used.
Production Insight
Middleware stacking order matters. If a custom middleware calls Auth::user() but runs before the 'auth' middleware, it will get null. Always ensure auth middleware runs first: middleware(['auth', 'custom_middleware']). Check the middleware order in app/Http/Kernel.php if you have global middleware that depends on auth state.
Key Takeaway
Use middleware('auth') to protect routes. Use Auth::check() and Auth::user() for inline checks. Use @auth and @guest in Blade templates. In multi-guard apps, always specify the guard explicitly. Middleware stacking order matters — auth must run before any middleware that depends on auth state.
Auth State Checking Strategy
IfRoute requires authentication
→
UseUse middleware('auth') on the route or route group. Do not check Auth::check() in the controller.
IfRoute requires email verification
→
UseUse middleware(['auth', 'verified']). The 'verified' middleware redirects unverified users to the verification page.
IfRoute requires specific role or permission
→
UseUse a policy or a custom middleware (e.g., middleware('can:admin')). Do not check roles in the controller.
IfBlade template needs conditional content based on auth state
→
UseUse @auth and @guest directives. Use @auth('admin') for multi-guard checks.
Laravel Sanctum: API Authentication for SPAs and Mobile Apps
Laravel Sanctum is the recommended authentication system for APIs consumed by first-party applications (SPAs, mobile apps). It replaces the complexity of OAuth2 for most use cases with a simple token-based system.
How Sanctum works: Sanctum issues API tokens stored in a personal_access_tokens table. Each token has abilities (permissions) and an optional expiration. Tokens are sent as Bearer tokens in the Authorization header. Sanctum also supports cookie-based authentication for SPAs served from the same domain — the SPA sends requests to the Laravel backend with the session cookie, and Sanctum authenticates via the session (no token needed).
SPA authentication (cookie-based): When your SPA and Laravel backend are on the same domain (or subdomain), Sanctum uses session cookies for authentication. The SPA makes a request to /sanctum/csrf-cookie to get a CSRF token, then sends subsequent requests with the session cookie. This is simpler than token-based auth and avoids token storage in the SPA.
Token authentication: For mobile apps or third-party integrations, Sanctum issues API tokens. The user generates a token via a UI, and the mobile app sends the token as a Bearer token. Tokens can have abilities (e.g., 'read', 'write') that are checked with $token->can('read').
Sanctum vs Passport: Sanctum is lightweight — no OAuth2 flows, no authorization servers, no grant types. Passport implements full OAuth2 and is appropriate for public APIs where third-party applications need delegated access. For first-party apps, Sanctum is the right choice.
io/thecodeforge/sanctum-setup.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<?php
// ============================================================// Step 1: Install and configure Sanctum// ============================================================// composer require laravel/sanctum// php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"// php artisan migrate// ============================================================// Step 2: routes/api.php — protect API routes with Sanctum// ============================================================useIlluminate\Support\Facades\Route;
useApp\Http\Controllers\Api\ProfileController;
useApp\Http\Controllers\Api\PostController;
// Public API routes — no authentication requiredRoute::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
// Protected API routes — requires valid Sanctum token or session cookieRoute::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn($request) => $request->user());
Route::put('/profile', [ProfileController::class, 'update']);
Route::post('/posts', [PostController::class, 'store']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
// ============================================================// Step 3: Token issuance — user generates a token via UI// ============================================================namespaceApp\Http\Controllers\Api;
useApp\Http\Controllers\Controller;
useIlluminate\Http\Request;
useIlluminate\Http\JsonResponse;
classTokenControllerextendsController
{
/**
* Issue a newAPI token with specific abilities.
* The token is returned ONCE — store it securely on the client.
*/
publicfunctionstore(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:255',
'abilities' => 'array|nullable',
]);
$token = $request->user()->createToken(
$request->input('name', 'api-token'),
$request->input('abilities', ['read', 'write'])
);
returnresponse()->json([
'token' => $token->plainTextToken, // shown ONCE — never stored in plain text'abilities' => $token->accessToken->abilities,
], 201);
}
/**
* Revoke a specific token.
*/
publicfunctiondestroy(Request $request, int $tokenId): JsonResponse
{
$request->user()->tokens()->where('id', $tokenId)->delete();
returnresponse()->json(['message' => 'Token revoked.']);
}
/**
* Revoke all tokens (nuclear option — used on password change).
*/
publicfunctiondestroyAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
returnresponse()->json(['message' => 'All tokens revoked.']);
}
}
Sanctum: first-party apps (your SPA, your mobile app). Simple token-based auth, no OAuth2 complexity.
Passport: third-party apps (public API where other developers build integrations). Full OAuth2 with authorization flows.
Sanctum supports both cookie-based auth (for SPAs on the same domain) and token-based auth (for mobile apps).
For 90% of applications, Sanctum is the right choice. Passport is overkill unless you need delegated access.
Production Insight
Always revoke all tokens on password change ($user->tokens()->delete()). If an attacker has a valid token and the user changes their password, the attacker's token remains valid unless explicitly revoked. This is the most common Sanctum security oversight.
Key Takeaway
Sanctum provides simple API authentication for first-party apps. Cookie-based auth for SPAs on the same domain. Token-based auth for mobile apps and third-party integrations. Always revoke tokens on password change. Sanctum replaces Passport for most use cases.
Password Hashing, Session Security, and Hardening
Laravel's authentication security depends on three pillars: password hashing, session configuration, and cookie security. Misconfiguring any one of these creates a vulnerability.
Password hashing: Laravel uses bcrypt by default via the Hash facade. bcrypt is adaptive — the cost parameter (configurable in config/hashing.php) determines how many rounds of hashing are applied. Higher cost = slower hashing = more resistant to brute-force. The default cost of 10 is acceptable for most applications. Increase to 12 for high-security environments (adds ~200ms per hash operation).
Session configuration: Key session settings in config/session.php: - driver: where sessions are stored (file, database, redis, cookie). Redis is recommended for production — fast and supports session locking. - lifetime: session duration in minutes. Default 120 (2 hours). Shorter is more secure but requires more frequent re-authentication. - expire_on_close: if true, sessions end when the browser closes. Useful for public computers. - secure: if true, session cookies are only sent over HTTPS. MUST be true in production. - http_only: if true, JavaScript cannot access the session cookie. MUST be true. - same_site: controls cross-site cookie sending. Lax is recommended — Strict breaks OAuth redirects.
Cookie hardening: Use the __Host- cookie prefix for session cookies. This requires the cookie to be Secure, Path=/, and have no Domain attribute. This prevents subdomain cookie injection attacks. Configure in config/session.php: 'cookie' => '__Host-laravel_session'.
io/thecodeforge/auth-hardening.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php
// ============================================================// config/hashing.php — password hashing configuration// ============================================================return [
'driver' => 'bcrypt',
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10), // increase to 12 for high-security
],
'argon' => [
'memory' => 65536, // 64MB — Argon2 memory cost
'threads' => 1, // parallelism
'time' => 4, // time cost
],
];
// ============================================================// config/session.php — session security settings// ============================================================return [
'driver' => env('SESSION_DRIVER', 'redis'), // redis for production
'lifetime' => env('SESSION_LIFETIME', 120), // minutes
'expire_on_close' => false, // true for public computers
'encrypt' => false, // true if session data is sensitive
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only — MUST be true
'http_only' => true, // JS cannot access cookie — MUST be true
'same_site' => env('SESSION_SAME_SITE', 'lax'), // lax recommended'cookie' => env('SESSION_COOKIE', '__Host-laravel_session'),
'domain' => env('SESSION_DOMAIN', null), // null required for __Host- prefix
'path' => '/', // '/' required for __Host- prefix
];
// ============================================================// Middleware: force password re-confirmation for sensitive actions// ============================================================namespaceApp\Http\Middleware;
useClosure;
useIlluminate\Http\Request;
useIlluminate\Support\Carbon;
useSymfony\Component\HttpFoundation\Response;
classPasswordConfirmedRecently
{
/**
* Ensure the user confirmed their password within the last 3 hours.
* Usedfor sensitive actions: changing email, deleting account, viewing API keys.
*/
publicfunctionhandle(Request $request, Closure $next): Response
{
if (!session()->has('password_confirmed_at')) {
returnredirect()->route('password.confirm');
}
$confirmedAt = Carbon::createFromTimestamp(session('password_confirmed_at'));
if ($confirmedAt->addSeconds(config('auth.password_timeout', 10800))->isPast()) {
returnredirect()->route('password.confirm');
}
return $next($request);
}
}
Each additional round doubles the hashing time. Round 10 takes ~80ms. Round 12 takes ~320ms.
Slower hashing makes brute-force attacks 4x more expensive for attackers.
The trade-off: legitimate login takes ~320ms longer. This is acceptable for most applications.
Do not go above 12 — the diminishing returns are not worth the user experience impact.
Production Insight
SESSION_SECURE_COOKIE must be true in production. If it is false, session cookies are sent over HTTP, allowing network attackers to intercept them. This is a critical misconfiguration that is easy to miss during development (where HTTPS is not used). Set SESSION_SECURE_COOKIE=true in your production .env and verify with php artisan config:show session.secure.
Key Takeaway
Password hashing uses bcrypt with configurable rounds — increase to 12 for high-security. Session cookies must be Secure (HTTPS only), HttpOnly (no JS access), and SameSite=Lax. Use __Host- cookie prefix to prevent subdomain injection. Always revoke tokens on password change.
● Production incidentPOST-MORTEMseverity: high
Session Fixation Attack Compromises 200 Admin Accounts via Missing session()->regenerate()
Symptom
The security team noticed unusual admin panel activity — configuration changes, user deletions, and API key rotations happening outside business hours. The audit log showed admin accounts performing actions, but the admins confirmed they were not logged in during those times. Session IDs in the audit log matched session IDs that had been set before the admin's login timestamp.
Assumption
The team assumed a compromised admin password — they forced password resets for all admins. The attacks continued after password resets. They assumed a CSRF vulnerability — CSRF tokens were correctly implemented. They assumed a database breach — the sessions table showed no unauthorized inserts. The actual issue was simpler: the login controller did not regenerate the session ID after authentication.
Root cause
The custom login controller called Auth::attempt() but did not call $request->session()->regenerate() afterward. An attacker sent a phishing email to admins containing a <img src="https://evil.com/track"> tag. When the admin's browser loaded the image, the attacker's server set a known session ID cookie on the admin's browser (via a redirect that set Set-Cookie: laravel_session=ATTACKER_KNOWN_ID). When the admin later logged in, Laravel reused the attacker's session ID and marked it as authenticated. The attacker could then use the same session ID to access the admin panel.
Fix
1. Added $request->session()->regenerate() immediately after every successful Auth::attempt() call. 2. Added a middleware that regenerates the session ID on every authentication state change. 3. Configured session.cookie to use the __Host- prefix (requires Secure, Path=/, no Domain) to prevent subdomain cookie injection. 4. Set SameSite=Lax on session cookies to prevent cross-site cookie sending. 5. Added session fixation detection: logged when a session ID was reused across an authentication boundary. 6. Rotated all admin sessions and invalidated all existing session tokens.
Key lesson
session()->regenerate() is not optional — it is the primary defense against session fixation. Without it, an attacker can pre-seed a session ID and inherit an authenticated session.
Always regenerate the session ID after any authentication state change: login, role change, privilege escalation.
Use __Host- cookie prefix and SameSite=Lax to prevent subdomain and cross-site cookie injection.
Audit log session IDs across authentication boundaries. If a session ID exists before login and is reused after login, it indicates a fixation attempt.
Breeze includes session regeneration automatically. Custom login controllers must implement it explicitly.
Production debug guideFrom null users to broken redirects — systematic debugging paths for auth failures.6 entries
Symptom · 01
Auth::user() returns null even though the user is logged in.
→
Fix
Check if you are using the correct guard. In a multi-guard app, Auth::user() uses the default guard (usually 'web'). If the user authenticated via a different guard, use Auth::guard('admin')->user(). Check config/auth.php defaults.guard. Check if the session driver is working: dd(session()->all()). If the session is empty, the session driver is misconfigured.
Symptom · 02
Login succeeds but the user is immediately redirected back to /login.
→
Fix
Check if the session is being regenerated and the new session ID is being sent to the browser. Check if the session driver is 'cookie' and the cookie size exceeds 4KB. Check if the session domain in config/session.php matches the application domain. Check if SameSite=Strict is blocking the session cookie on redirects from external sites.
Symptom · 03
'Route [login] not defined' error when accessing a protected route as a guest.
→
Fix
The auth middleware redirects to a route named 'login'. Check if your login route is named 'login': php artisan route:list --name=login. If your login route has a different name, either rename it to 'login' or override the redirectTo() method in app/Http/Middleware/Authenticate.php.
Symptom · 04
API returns 401 Unauthorized even with a valid token.
→
Fix
Check if the route uses the correct guard: middleware('auth:api'). Check if the token is being sent as a Bearer token in the Authorization header. Check if Sanctum is configured correctly: check config/sanctum.php. Check if the token has expired or been revoked. Check if the user model uses the HasApiTokens trait.
Symptom · 05
Rate limiter blocks legitimate users after failed login attempts.
→
Fix
Check the rate limit key — is it per-email+IP or just per-IP? Per-IP rate limiting can block shared IPs (office networks, NAT). Check RateLimiter::availableIn($key) for the remaining cooldown. Check if the rate limiter is cleared on successful login: RateLimiter::clear($key). Check if the decay window is too long (60s is standard).
Symptom · 06
Password reset tokens expire too quickly or are accepted after expiry.
→
Fix
Check config/auth.php passwords.users.expire (default: 60 minutes). Check if the password_reset_tokens table has the correct created_at timestamp. Check if the server timezone matches the database timezone — a mismatch causes premature expiry. Check if the throttle setting prevents rapid token re-requests.
★ Laravel Authentication Triage Cheat SheetFirst-response commands when login fails, sessions break, or auth state is inconsistent.
If created_at in DB is wrong, check timezone: php artisan config:show app.timezone vs MySQL SELECT NOW().
Users report being logged out randomly between requests.+
Immediate action
Check session driver, lifetime, and cookie configuration.
Commands
php artisan config:show session.driver
php artisan config:show session.lifetime
Fix now
If driver is 'file', check storage/framework/sessions/ permissions. If driver is 'database', check sessions table exists. Increase SESSION_LIFETIME if too short.
Laravel Authentication Options: Starter Kits, Guards, and API Auth
Feature / Aspect
Laravel Breeze
Laravel Jetstream
Laravel Sanctum
Laravel Passport
Complexity
Minimal — ~8 controllers, easy to read
Full-featured — much more generated code
Lightweight — token management only
Heavy — full OAuth2 server
Two-Factor Auth (2FA)
Not included — add manually
Built-in via Fortify
Not included
Not included
API Token Management
Not included
Built-in via Sanctum
Core feature
Core feature (OAuth2 tokens)
Team / Multi-tenancy
Not included
Built-in team switching
Not included
Not included
SPA Cookie Auth
Not included
Included via Sanctum
Core feature
Not supported
OAuth2 Support
No
No
No
Yes — full OAuth2 spec
Session Management UI
Not included
View and revoke active sessions
Token management via API
Client management via UI
Customisability
Very easy — you own every line
Harder — Fortify actions tricky to override
Easy — thin layer on top of tokens
Complex — OAuth2 spec is rigid
Best For
Custom apps, learning, most projects
SaaS products needing 2FA + teams
First-party SPAs and mobile apps
Public APIs with third-party integrations
Token Abilities
N/A
Via Sanctum
Yes — scoped abilities
Yes — OAuth2 scopes
Key takeaways
1
Guards define HOW auth state is stored (session vs token); providers define WHERE users are fetched from (Eloquent model vs raw table). They're independent
you can mix and match in config/auth.php.
2
Always call $request->session()->regenerate() immediately after a successful login to prevent session fixation attacks. This one line is the difference between secure and vulnerable.
3
Use Breeze for the vast majority of projects
it generates readable, owned code. Only reach for Jetstream when you need 2FA, team management, or Sanctum-powered API tokens from day one.
4
Auth::attempt() is just three steps under the hood
look up user by credentials, run Hash::check() on the password, write the user ID to the session. Understanding this means you can replicate or customise it anywhere.
5
Sanctum is the right choice for first-party APIs (SPAs, mobile apps). Passport is for public APIs with third-party integrations. Always revoke tokens on password change.
6
Session cookies must be Secure, HttpOnly, and SameSite=Lax in production. Use __Host- cookie prefix to prevent subdomain injection. SESSION_SECURE_COOKIE=false in production is a critical misconfiguration.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
How do I check if a user is logged in with Laravel?
Use Auth::check() which returns true for authenticated users and false for guests. In Blade templates, the @auth and @guest directives are cleaner alternatives. For a specific guard, use Auth::guard('api')->check() rather than the default-guard shorthand.
Was this helpful?
02
What is the difference between Laravel Sanctum and Laravel Passport?
Sanctum is lightweight and designed for SPAs, mobile apps, and simple token-based APIs — it issues plain tokens stored in your database with no OAuth overhead. Passport implements the full OAuth 2.0 specification and is appropriate when you need to issue tokens to third-party applications. For most first-party apps, Sanctum is the right choice; Passport is overkill unless you're building a public API platform.
Was this helpful?
03
Why does redirect()->intended() not always work after login?
intended() relies on the 'url.intended' value Laravel stores in the session when the auth middleware redirects a guest. If the session was cleared between the redirect and the login (e.g. due to a config cache clear during development, or a session driver misconfiguration), that value is lost. Always provide a fallback route as the first argument: redirect()->intended(route('dashboard')) — that ensures a sensible default even when the intended URL is gone.
Was this helpful?
04
How do I implement two-factor authentication in Laravel without Jetstream?
Use the pragmarx/google2fa-laravel package. Add a google2fa_secret column to the users table. On login, after Auth::attempt() succeeds, check if 2FA is enabled. If so, redirect to a 2FA verification page. The user enters a TOTP code from their authenticator app. Verify with Google2FA::verifyKey($user->google2fa_secret, $inputCode). Only complete the login (session regeneration, redirect) after 2FA verification passes.
Was this helpful?
05
What happens if I use Auth::attempt() without session()->regenerate()?
The login succeeds, but the session ID remains the same as before authentication. An attacker who pre-seeded that session ID (via a phishing email or malicious link) can use the same session ID to access the authenticated session. This is a session fixation vulnerability. Always call session()->regenerate() immediately after Auth::attempt() returns true.
Was this helpful?
06
How do I handle authentication in a Laravel API consumed by a mobile app?
Use Laravel Sanctum. The mobile app sends credentials to an endpoint like /api/login. The endpoint issues a Sanctum token: $user->createToken('mobile-app')->plainTextToken. The mobile app stores the token and sends it as a Bearer token in the Authorization header on subsequent requests. Protect API routes with middleware('auth:sanctum'). Revoke tokens on logout or password change.