Skip to content
Home PHP Laravel Authentication Explained — Starter Kits, Guards, and Custom Auth

Laravel Authentication Explained — Starter Kits, Guards, and Custom Auth

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Laravel → Topic 8 of 15
Laravel authentication demystified: learn how guards, providers, and starter kits work together, build custom login logic, and avoid the mistakes most devs make.
⚙️ Intermediate — basic PHP knowledge assumed
In this tutorial, you'll learn
Laravel authentication demystified: learn how guards, providers, and starter kits work together, build custom login logic, and avoid the mistakes most devs make.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
Laravel Authentication Triage Cheat Sheet
First-response commands when login fails, sessions break, or auth state is inconsistent.
🟡Auth::user() returns null for a logged-in user.
Immediate ActionCheck guard and session state.
Commands
php artisan tinker --execute="dd(auth()->guard(), auth()->check(), session()->all())"
php artisan config:show auth.defaults.guard
Fix NowIf guard is wrong, use Auth::guard('correct_guard')->user(). If session is empty, check SESSION_DRIVER in .env.
🟡Login redirects back to /login immediately after successful auth.
Immediate ActionCheck session cookie and domain configuration.
Commands
php artisan config:show session.domain
php artisan config:show session.same_site
Fix NowIf domain is wrong, fix SESSION_DOMAIN in .env. If SameSite=Strict, change to Lax for redirect-based auth flows.
🟡Route [login] not defined error on protected routes.
Immediate ActionCheck if login route exists and is named correctly.
Commands
php artisan route:list --name=login
grep -rn '->name(' routes/ | grep login
Fix NowAdd ->name('login') to the login route, or override redirectTo() in Authenticate middleware.
🟡API returns 401 with valid Bearer token.
Immediate ActionCheck guard, token format, and Sanctum configuration.
Commands
php artisan tinker --execute="dd(auth('api')->check(), request()->bearerToken())"
php artisan config:show auth.guards.api
Fix NowEnsure route uses middleware('auth:sanctum') for Sanctum tokens or middleware('auth:api') for Passport tokens.
🟡Legitimate users blocked by rate limiter after failed attempts.
Immediate ActionCheck rate limit key and remaining cooldown.
Commands
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')"
Fix NowClear the rate limiter for the affected user/IP. Review rate limit key to ensure it includes email (not just IP).
🟡Session fixation suspected — same session ID before and after login.
Immediate ActionCheck if session()->regenerate() is called after login.
Commands
grep -rn 'session()->regenerate' app/Http/Controllers/Auth/
php artisan tinker --execute="dd(session()->getId())"
Fix NowAdd $request->session()->regenerate() immediately after every successful Auth::attempt() call.
🟡Password reset token rejected as expired even though it was just generated.
Immediate ActionCheck token expiry config and server/database timezone alignment.
Commands
php artisan config:show auth.passwords.users.expire
php artisan tinker --execute="use Illuminate\Support\Facades\DB; dd(DB::table('password_reset_tokens')->latest()->first())"
Fix NowIf 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 ActionCheck session driver, lifetime, and cookie configuration.
Commands
php artisan config:show session.driver
php artisan config:show session.lifetime
Fix NowIf driver is 'file', check storage/framework/sessions/ permissions. If driver is 'database', check sessions table exists. Increase SESSION_LIFETIME if too short.
Production IncidentSession Fixation Attack Compromises 200 Admin Accounts via Missing session()->regenerate()A SaaS platform deployed a custom login controller without calling $request->session()->regenerate() after Auth::attempt(). An attacker pre-seeded session cookies on admin machines via a phishing email containing a tracking pixel. When admins logged in, the attacker inherited their authenticated sessions and accessed the admin panel with full privileges.
SymptomThe 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.
AssumptionThe 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 causeThe 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.
Fix1. 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.
Auth::user() returns null even though the user is logged in.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.
Login succeeds but the user is immediately redirected back to /login.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.
'Route [login] not defined' error when accessing a protected route as a guest.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.
API returns 401 Unauthorized even with a valid token.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.
Rate limiter blocks legitimate users after failed login attempts.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).
Password reset tokens expire too quickly or are accepted after expiry.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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
<?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)

];
▶ 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.
Mental Model
Guards, Providers, and Drivers as a Security Checkpoint
Why would you define a custom guard instead of just using the default 'web' guard?
  • 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.sh · BASH
1234567891011121314151617181920212223
# --- 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
▶ Output
GET|HEAD / ........................................................ home
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
Mental Model
Starter Kits as Prefab Houses
Why should most teams start with Breeze instead of Jetstream?
  • 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.

app/Http/Controllers/Auth/CustomLoginController.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
<?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');
    }
}
▶ 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."] }
// }
Mental Model
Login Flow as a Security Airlock
Why is the rate limit key built from email AND IP, not just IP?
  • 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.
📊 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.

app/Http/Controllers/Auth/RouteProtectionExamples.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
<?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>
*/
▶ Output
// Unauthenticated request to GET /dashboard:
// 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)
Mental Model
Middleware as Checkpoints on a Path
What is the difference between Auth::user() and $request->user()?
  • 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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
<?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.']);
    }
}
▶ Output
// Token issuance response:
// 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." }
Mental Model
Sanctum Tokens as Hotel Key Cards
When should you use Sanctum vs Passport?
  • 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.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
<?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);
    }
}
▶ Output
// Verify hashing configuration:
// 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)
Mental Model
Security Hardening as Layers of an Onion
Why should you increase bcrypt rounds from 10 to 12 for high-security applications?
  • 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.
🗂 Laravel Authentication Options: Starter Kits, Guards, and API Auth
When to use each authentication approach based on project requirements.
Feature / AspectLaravel BreezeLaravel JetstreamLaravel SanctumLaravel Passport
ComplexityMinimal — ~8 controllers, easy to readFull-featured — much more generated codeLightweight — token management onlyHeavy — full OAuth2 server
Two-Factor Auth (2FA)Not included — add manuallyBuilt-in via FortifyNot includedNot included
API Token ManagementNot includedBuilt-in via SanctumCore featureCore feature (OAuth2 tokens)
Team / Multi-tenancyNot includedBuilt-in team switchingNot includedNot included
SPA Cookie AuthNot includedIncluded via SanctumCore featureNot supported
OAuth2 SupportNoNoNoYes — full OAuth2 spec
Session Management UINot includedView and revoke active sessionsToken management via APIClient management via UI
CustomisabilityVery easy — you own every lineHarder — Fortify actions tricky to overrideEasy — thin layer on top of tokensComplex — OAuth2 spec is rigid
Best ForCustom apps, learning, most projectsSaaS products needing 2FA + teamsFirst-party SPAs and mobile appsPublic APIs with third-party integrations
Token AbilitiesN/AVia SanctumYes — scoped abilitiesYes — 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

    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.

    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.

    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.

    Not revoking API tokens on password change
    Symptom

    an attacker with a stolen API token continues to have access even after the user changes their password.

    Fix

    call $user->tokens()->delete() in the password change controller. This revokes all Sanctum tokens for the user.

    Using SESSION_SECURE_COOKIE=false in production
    Symptom

    session cookies are sent over HTTP, allowing network attackers to intercept them and hijack sessions.

    Fix

    set SESSION_SECURE_COOKIE=true in the production .env file. Verify with php artisan config:show session.secure.

    Returning different error messages for 'user not found' vs 'wrong password'
    Symptom

    attackers can enumerate valid email addresses by observing which error message is returned.

    Fix

    always return the same generic message: 'These credentials do not match our records.' Never distinguish between the two failure modes.

    Running Auth::user() multiple times in a request without caching
    Symptom

    unnecessary session reads on every call. While not a security issue, it adds latency in high-traffic applications.

    Fix

    cache the result: $user = Auth::user() once, then reuse $user throughout the request.

    Using SameSite=Strict on session cookies in an OAuth flow
    Symptom

    OAuth redirects from the identity provider back to your app fail because the session cookie is not sent on cross-site redirects.

    Fix

    use SameSite=Lax, which allows cookies on top-level navigations (redirects) but blocks them on cross-site subrequests (CSRF).

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.

🔥
Naren Founder & Author

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.

← PreviousLaravel MiddlewareNext →Laravel REST API Development
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged