Laravel Authentication Explained — Starter Kits, Guards, and Custom Auth
- 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.
- 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.
- 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.
- 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
Auth::user() returns null for a logged-in user.
php artisan tinker --execute="dd(auth()->guard(), auth()->check(), session()->all())"php artisan config:show auth.defaults.guardLogin redirects back to /login immediately after successful auth.
php artisan config:show session.domainphp artisan config:show session.same_siteRoute [login] not defined error on protected routes.
php artisan route:list --name=logingrep -rn '->name(' routes/ | grep loginAPI returns 401 with valid Bearer token.
php artisan tinker --execute="dd(auth('api')->check(), request()->bearerToken())"php artisan config:show auth.guards.apiLegitimate users blocked by rate limiter after failed attempts.
php artisan tinker --execute="use Illuminate\Support\Facades\RateLimiter; dd(RateLimiter::attempts('login|127.0.0.1'))"php artisan tinker --execute="use Illuminate\Support\Facades\RateLimiter; RateLimiter::clear('login|127.0.0.1')"Session fixation suspected — same session ID before and after login.
grep -rn 'session()->regenerate' app/Http/Controllers/Auth/php artisan tinker --execute="dd(session()->getId())"Password reset token rejected as expired even though it was just generated.
php artisan config:show auth.passwords.users.expirephp artisan tinker --execute="use Illuminate\Support\Facades\DB; dd(DB::table('password_reset_tokens')->latest()->first())"Users report being logged out randomly between requests.
php artisan config:show session.driverphp artisan config:show session.lifetimeProduction Incident
Production Debug GuideFrom null users to broken redirects — systematic debugging paths for auth failures.
session()->all()). If the session is empty, the session driver is misconfigured.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.
<?php return [ /* |-------------------------------------------------------------------------- | Default Authentication Guard |-------------------------------------------------------------------------- | 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 ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | 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 ], ], /* |-------------------------------------------------------------------------- | Password Reset Configuration |-------------------------------------------------------------------------- | 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) ];
// When you run: php artisan config:show auth
// Laravel prints the resolved config array so you can verify your changes.
- 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.
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.
# --- Install Laravel Breeze --- # Step 1: Require the package (development dependency only) composer require laravel/breeze --dev # Step 2: Scaffold the auth views and controllers. # Options: blade | react | vue | api | livewire | livewire-functional php artisan breeze:install blade # Step 3: Run migrations — creates users, sessions, password_reset_tokens tables php artisan migrate # Step 4: Compile front-end assets (Breeze uses Tailwind CSS) npm install && npm run dev # --- What Breeze 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
GET|HEAD dashboard ....................... dashboard › [auth, verified]
GET|HEAD login ................... login › Auth\AuthenticatedSessionController@create
POST login .................. Auth\AuthenticatedSessionController@store
POST logout ................. logout › Auth\AuthenticatedSessionController@destroy
GET|HEAD register ............... register › Auth\RegisteredUserController@create
POST register ............... Auth\RegisteredUserController@store
GET|HEAD forgot-password ........ password.request
POST forgot-password ........ password.email
GET|HEAD reset-password/{token} . password.reset
POST reset-password ......... password.update
GET|HEAD verify-email ........... verification.notice
GET|HEAD verify-email/{id}/{hash} verification.verify
POST email/verification-notification ... verification.send
- 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.
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 namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class CustomLoginController extends Controller { /** * 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. */ public function showLoginForm() { return view('auth.login'); } /** * Handle a login attempt. * We validate, rate-limit, attempt auth, then redirect. */ public function login(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 seconds if (RateLimiter::tooManyAttempts($rateLimitKey, maxAttempts: 5)) { $secondsRemaining = RateLimiter::availableIn($rateLimitKey); // Throw a ValidationException — Laravel formats this nicely // in both Blade views and JSON responses automatically throw ValidationException::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 login RateLimiter::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. return redirect()->intended(route('dashboard')); } // 8. Failed attempt — increment the rate limiter counter RateLimiter::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). throw ValidationException::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. */ public function logout(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 token return redirect()->route('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."] }
// }
- Per-IP rate limiting blocks shared IPs (office networks, NAT gateways) — one user's failed attempts block everyone.
- Per-email rate limiting allows distributed brute-force — attacker uses 1000 IPs to try 5 passwords each.
- 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.
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 // ============================================================ use Illuminate\Support\Facades\Route; use App\Http\Controllers\DashboardController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\Admin\AdminDashboardController; // Public route — no middleware, anyone can access Route::get('/', fn() => view('welcome'))->name('home'); // Single protected route Route::get('/dashboard', [DashboardController::class, 'index']) ->middleware('auth') // redirects guests to /login ->name('dashboard'); // Group of protected routes — cleaner than repeating middleware Route::middleware(['auth', 'verified'])->group(function () { // 'verified' means the user must also have confirmed their email Route::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 provider Route::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 // ============================================================ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class DashboardController extends Controller { public function index(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(); return view('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 ONLY for authenticated users --}} @auth <span>Welcome, {{ Auth::user()->name }}</span> <form method="POST" action="{{ route('logout') }}"> @csrf <button type="submit">Log Out</button> </form> @endauth {{-- @guest renders its contents ONLY for unauthenticated visitors --}} @guest <a href="{{ route('login') }}">Log In</a> <a href="{{ route('register') }}">Register</a> @endguest {{-- Multi-guard check in Blade: check a specific guard --}} @auth('admin') <a href="{{ route('admin.dashboard') }}">Admin Panel</a> @endauth </nav> */
// HTTP 302 → /login
// (After login, redirect()->intended() sends them back to /dashboard)
// Authenticated request to GET /dashboard:
// HTTP 200 — dashboard view rendered
// Auth::user() returns: App\Models\User { id: 42, name: 'Sarah Connor', email: '...' }
// auth()->id() returns: 42
// Request to GET /dashboard with email unverified + 'verified' middleware:
// HTTP 302 → /email/verify (the verification.notice named route)
- 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.
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.
<?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 // ============================================================ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\ProfileController; use App\Http\Controllers\Api\PostController; // Public API routes — no authentication required Route::get('/posts', [PostController::class, 'index']); Route::get('/posts/{post}', [PostController::class, 'show']); // Protected API routes — requires valid Sanctum token or session cookie Route::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 // ============================================================ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; class TokenController extends Controller { /** * Issue a new API token with specific abilities. * The token is returned ONCE — store it securely on the client. */ public function store(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']) ); return response()->json([ 'token' => $token->plainTextToken, // shown ONCE — never stored in plain text 'abilities' => $token->accessToken->abilities, ], 201); } /** * Revoke a specific token. */ public function destroy(Request $request, int $tokenId): JsonResponse { $request->user()->tokens()->where('id', $tokenId)->delete(); return response()->json(['message' => 'Token revoked.']); } /** * Revoke all tokens (nuclear option — used on password change). */ public function destroyAll(Request $request): JsonResponse { $request->user()->tokens()->delete(); return response()->json(['message' => 'All tokens revoked.']); } }
// HTTP 201
// {
// "token": "1|abc123def456...",
// "abilities": ["read", "write"]
// }
// Authenticated API request:
// curl -H "Authorization: Bearer 1|abc123def456..." https://api.example.com/user
// HTTP 200
// { "id": 42, "name": "Sarah Connor", "email": "sarah@example.com" }
// Revoked token request:
// curl -H "Authorization: Bearer 1|abc123def456..." https://api.example.com/user
// HTTP 401 { "message": "Unauthenticated." }
- 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.
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'.
<?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 // ============================================================ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Symfony\Component\HttpFoundation\Response; class PasswordConfirmedRecently { /** * Ensure the user confirmed their password within the last 3 hours. * Used for sensitive actions: changing email, deleting account, viewing API keys. */ public function handle(Request $request, Closure $next): Response { if (!session()->has('password_confirmed_at')) { return redirect()->route('password.confirm'); } $confirmedAt = Carbon::createFromTimestamp(session('password_confirmed_at')); if ($confirmedAt->addSeconds(config('auth.password_timeout', 10800))->isPast()) { return redirect()->route('password.confirm'); } return $next($request); } }
// php artisan tinker --execute="dd(config('hashing.bcrypt.rounds'))"
// Output: 10
// Verify session configuration:
// php artisan config:show session.secure
// Output: true
// php artisan config:show session.same_site
// Output: "lax"
// Test password hashing speed:
// php artisan tinker --execute="use Illuminate\Support\Facades\Hash; \$start=microtime(true); Hash::make('password'); echo microtime(true)-\$start;"
// Output: ~0.08s (cost 10) or ~0.3s (cost 12)
- 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.
| 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between a guard and a provider in Laravel authentication, and when would you define a custom one of each?
- QHow does Laravel's Auth::attempt() method work internally — what exactly happens between receiving the credentials array and writing data to the session?
- QIf you have a multi-guard application with 'web' and 'api' guards, and a user logs in via the web guard, are they also authenticated when a request hits a route protected by the 'api' guard? Why or why not?
- QExplain the session fixation attack and how Laravel prevents it. What happens if you forget
session()->regenerate() after login? - QHow does Laravel Sanctum handle authentication differently for SPAs (cookie-based) vs mobile apps (token-based)? When would you choose Passport over Sanctum?
- QWhat is user enumeration and how do you prevent it in a Laravel login controller? Why must the error message be the same for 'user not found' and 'wrong password'?
- QWalk me through the security hardening checklist for a Laravel authentication system in production: password hashing, session config, cookie settings.
Frequently Asked Questions
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.
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.
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.
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.
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.
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.