Laravel Authentication Explained — Starter Kits, Guards, and Custom Auth
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.
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.
<?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 ], ], /* |-------------------------------------------------------------------------- | 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 ], ], /* |-------------------------------------------------------------------------- | 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.
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, 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.
# --- 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
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.
<?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."] }
// }
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.
<?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)
| Feature / Aspect | Laravel Breeze | Laravel Jetstream |
|---|---|---|
| Complexity | Minimal — ~8 controllers, easy to read | Full-featured — much more generated code |
| Two-Factor Auth (2FA) | Not included — add manually | Built-in via Fortify |
| API Token Management | Not included | Built-in via Laravel Sanctum |
| Team / Multi-tenancy | Not included | Built-in team switching |
| Front-end Options | Blade, Vue (Inertia), React (Inertia), Livewire | Livewire or Inertia (Vue/React) |
| Session Management UI | Not included | View and revoke active sessions |
| Customisability | Very easy — you own every line | Harder — Fortify actions can be tricky to override |
| Best For | Custom apps, learning, most projects | SaaS products needing 2FA + teams out of the box |
| Password Confirmation | Included | Included |
| Email Verification | Included (optional) | Included (optional) |
🎯 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting session()->regenerate() after Auth::attempt() — Symptom: session fixation vulnerability (no immediate error, but a serious security hole an attacker can exploit by pre-seeding a session ID). Fix: always call $request->session()->regenerate() as the very first line after a successful Auth::attempt() returns true. Laravel Breeze does this; your custom code must too.
- ✕Mistake 2: Using Auth::user() outside the default guard in a multi-guard app — Symptom: Auth::user() returns null even though the admin is clearly logged in, because the admin uses a different guard. Fix: always specify the guard explicitly — Auth::guard('admin')->user() — or use $request->user('admin'). Never assume the default guard is the one you want.
- ✕Mistake 3: Not naming the login route 'login' in a custom auth setup — Symptom: the 'auth' middleware redirects unauthenticated users to a route named 'login', but your custom route is named 'auth.login' or 'user.login', causing a Symfony RouteNotFoundException with 'Route [login] not defined'. Fix: either name your login route exactly 'login', or override the redirectTo method in app/Http/Middleware/Authenticate.php to return your custom route name or URL.
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?
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.