Backend

Building Authentication with Laravel Sanctum

Mayur Dabhi
Mayur Dabhi
April 16, 2026
14 min read

Authentication is one of the most critical aspects of any web application. Laravel Sanctum provides a featherweight authentication system for Single Page Applications (SPAs), mobile applications, and simple token-based APIs. Unlike Laravel Passport, which implements the full OAuth2 specification, Sanctum is purposefully simple — yet powerful enough for the vast majority of real-world applications. In this guide, we'll dive deep into Sanctum's two authentication modes and build a fully functional auth system from scratch.

Sanctum vs Passport

Laravel Sanctum is ideal when you control both the API and the client consuming it (e.g., your own React or Vue SPA). If you need to issue tokens to third-party applications via OAuth2, use Laravel Passport instead. For most apps, Sanctum is the right choice — it's simpler, faster to set up, and easier to reason about.

How Laravel Sanctum Works

Sanctum operates in two distinct modes depending on your use case:

API Token Mode Mobile / CLI Sanctum Token Guard API Routes Bearer token personal_ access_tokens hash compare SPA Cookie Mode React SPA Sanctum Session Guard Cookie+CSRF Laravel Sanctum — Two Authentication Modes

Sanctum's dual-mode architecture: API token validation vs session-cookie-based SPA auth

Installation and Setup

Laravel Sanctum ships with Laravel 11+ by default. For older projects, install it via Composer:

1

Install Sanctum

Require the package and publish its configuration and migration files.

Terminal
# Install Sanctum (skip for Laravel 11+, it's included)
composer require laravel/sanctum

# Publish the config and migration
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# Run the migration (creates personal_access_tokens table)
php artisan migrate
2

Add HasApiTokens Trait

Add the HasApiTokens trait to your User model. This gives the model the ability to create and manage tokens.

app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];
}
3

Configure API Guard

In config/auth.php, ensure the api guard uses the sanctum driver (or keep the default — Sanctum's middleware handles guard selection automatically).

config/auth.php (excerpt)
'guards' => [
    'web' => [
        'driver'   => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver'   => 'sanctum',   // <-- change from 'token'
        'provider' => 'users',
    ],
],
Laravel 11 Difference

Laravel 11 removed config/auth.php from the default scaffold. Instead, configure the guard inside bootstrap/app.php using withMiddleware(), or publish the auth config with php artisan config:publish auth.

API Token Authentication

API token authentication is the most straightforward Sanctum mode. The user logs in, receives a plain-text token, and includes it as a Bearer token in every subsequent request header. Sanctum hashes the token before storing it — the plain text is only shown once at creation time.

Creating Auth Controller

Terminal — generate controller
php artisan make:controller Api/AuthController
app/Http/Controllers/Api/AuthController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    /**
     * Register a new user and issue a token.
     */
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name'     => 'required|string|max:255',
            'email'    => 'required|email|unique:users,email',
            'password' => 'required|string|min:8|confirmed',
        ]);

        $user = User::create([
            'name'     => $validated['name'],
            'email'    => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'user'         => $user,
            'access_token' => $token,
            'token_type'   => 'Bearer',
        ], 201);
    }

    /**
     * Authenticate an existing user and issue a token.
     */
    public function login(Request $request)
    {
        $request->validate([
            'email'       => 'required|email',
            'password'    => 'required|string',
            'device_name' => 'sometimes|string|max:255',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        // Optionally revoke old tokens from the same device
        // $user->tokens()->where('name', $request->device_name)->delete();

        $tokenName = $request->device_name ?? 'api_token';
        $token = $user->createToken($tokenName)->plainTextToken;

        return response()->json([
            'user'         => $user,
            'access_token' => $token,
            'token_type'   => 'Bearer',
        ]);
    }

    /**
     * Return the authenticated user's profile.
     */
    public function me(Request $request)
    {
        return response()->json($request->user());
    }

    /**
     * Revoke the current token (logout).
     */
    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json(['message' => 'Logged out successfully.']);
    }

    /**
     * Revoke all tokens (logout from all devices).
     */
    public function logoutAll(Request $request)
    {
        $request->user()->tokens()->delete();

        return response()->json(['message' => 'Logged out from all devices.']);
    }
}

Defining API Routes

routes/api.php
<?php

use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;

// Public auth routes
Route::prefix('auth')->group(function () {
    Route::post('/register', [AuthController::class, 'register']);
    Route::post('/login',    [AuthController::class, 'login']);
});

// Protected routes — require a valid Sanctum token
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user',        [AuthController::class, 'me']);
    Route::post('/logout',     [AuthController::class, 'logout']);
    Route::post('/logout-all', [AuthController::class, 'logoutAll']);

    // Your application routes
    Route::apiResource('posts', PostController::class);
});

Testing the API with cURL

Terminal — cURL examples
# Register
curl -X POST http://localhost:8000/api/auth/register \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"name":"John Doe","email":"john@example.com","password":"secret123","password_confirmation":"secret123"}'

# Response:
# {
#   "user": { "id": 1, "name": "John Doe", "email": "john@example.com" },
#   "access_token": "1|abc123def456...",
#   "token_type": "Bearer"
# }

# Login
curl -X POST http://localhost:8000/api/auth/login \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"email":"john@example.com","password":"secret123","device_name":"my-laptop"}'

# Fetch user profile (use the token from login)
curl http://localhost:8000/api/user \
  -H "Authorization: Bearer 1|abc123def456..." \
  -H "Accept: application/json"

# Logout
curl -X POST http://localhost:8000/api/logout \
  -H "Authorization: Bearer 1|abc123def456..." \
  -H "Accept: application/json"

Token Abilities (Scopes)

Sanctum lets you assign abilities (similar to OAuth scopes) to tokens. This enables fine-grained access control — for example, issuing a read-only token to a mobile app and a full-access token to an admin dashboard.

Issuing tokens with abilities
// Issue a read-only token
$token = $user->createToken('mobile-app', ['posts:read', 'profile:read'])
              ->plainTextToken;

// Issue an admin token with all abilities
$token = $user->createToken('admin-panel', ['*'])->plainTextToken;

// Check abilities in controllers / middleware
$request->user()->tokenCan('posts:read');   // true or false

// Abort if ability missing
$request->user()->tokenCant('posts:write')
    ? abort(403, 'This token cannot write posts.')
    : null;
Middleware-level ability checks
// In routes/api.php — requires both sanctum auth AND the ability
Route::put('/posts/{post}', [PostController::class, 'update'])
    ->middleware(['auth:sanctum', 'ability:posts:write']);

// Or check abilities on individual routes using closures
Route::delete('/posts/{post}', function (Request $request, Post $post) {
    if (! $request->user()->tokenCan('posts:delete')) {
        abort(403);
    }
    $post->delete();
    return response()->noContent();
})->middleware('auth:sanctum');
Ability Use Case Example Token Name
* Full admin access admin-dashboard
posts:read Read-only API consumers mobile-app
posts:write Content management tools cms-integration
profile:update Profile editing only profile-editor

SPA Authentication (Cookie-Based)

When your SPA (React, Vue, Angular) lives on the same top-level domain as your Laravel backend, cookie-based authentication is far more secure than storing tokens in localStorage — which is vulnerable to XSS attacks. Sanctum's SPA mode leverages Laravel's robust session and CSRF protection.

Configuring Stateful Domains

config/sanctum.php (stateful domains)
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000,::1',
    Sanctum::currentApplicationUrlWithPort()
))),
.env
# Add your SPA's domain (no trailing slash)
SANCTUM_STATEFUL_DOMAINS=localhost:3000,app.example.com

# Ensure session domain covers both API and SPA
SESSION_DOMAIN=.example.com

# For cross-subdomain cookies
SESSION_DRIVER=cookie

Adding Sanctum Middleware

For Laravel 10 and below, add Sanctum's middleware to the api middleware group in app/Http/Kernel.php:

app/Http/Kernel.php (Laravel 10)
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

For Laravel 11, configure it in bootstrap/app.php:

bootstrap/app.php (Laravel 11)
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
    // Or manually:
    // $middleware->prependToGroup('api', EnsureFrontendRequestsAreStateful::class);
})

SPA Login Flow (React Example)

The SPA must follow a specific three-step flow: fetch the CSRF cookie, post credentials, then make authenticated requests using the session cookie automatically sent by the browser.

React — api/auth.js (using axios)
import axios from 'axios';

// Configure axios base URL and credentials
axios.defaults.baseURL = 'http://localhost:8000';
axios.defaults.withCredentials = true;         // Send cookies cross-origin
axios.defaults.headers.common['Accept'] = 'application/json';

/**
 * Step 1: Fetch CSRF cookie (required before login/register)
 * Step 2: POST credentials to /login
 */
export async function loginUser(email, password) {
    // Fetch CSRF cookie from Laravel
    await axios.get('/sanctum/csrf-cookie');

    // Login — Laravel reads XSRF-TOKEN cookie and validates it automatically
    const response = await axios.post('/login', { email, password });
    return response.data;
}

export async function registerUser(name, email, password) {
    await axios.get('/sanctum/csrf-cookie');
    const response = await axios.post('/register', {
        name, email, password, password_confirmation: password
    });
    return response.data;
}

export async function logoutUser() {
    await axios.post('/logout');
}

export async function fetchUser() {
    const response = await axios.get('/api/user');
    return response.data;
}
React — Login component
import { useState } from 'react';
import { loginUser } from './api/auth';

export default function Login() {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState(null);

    const handleSubmit = async (e) => {
        e.preventDefault();
        setError(null);
        try {
            await loginUser(email, password);
            window.location.href = '/dashboard';
        } catch (err) {
            setError(err.response?.data?.message || 'Login failed');
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            {error && <div className="error">{error}</div>}
            <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
            <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
            <button type="submit">Login</button>
        </form>
    );
}
Why /sanctum/csrf-cookie?

This endpoint sets the XSRF-TOKEN cookie. Axios automatically reads this cookie and sends it as the X-XSRF-TOKEN header on subsequent requests. Laravel validates this header against the session, completing the CSRF protection cycle. You only need to call this once per session (typically before the first login or register request).

Token Expiration and Management

By default, Sanctum tokens never expire. For security-sensitive applications you should configure token expiration. Sanctum also provides a pruning command to clean up expired tokens from the database.

config/sanctum.php — expiration
/*
 * Token expiration in minutes. null means tokens never expire.
 * Set to 1440 for 24-hour tokens, 10080 for 7 days, etc.
 */
'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 1440),  // 24 hours

/*
 * Token expiration for refresh tokens (if implementing refresh)
 */
'refresh_token_expiration' => env('SANCTUM_REFRESH_EXPIRATION', 43200), // 30 days
Schedule token pruning (app/Console/Kernel.php or routes/console.php)
// Laravel 10 — app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    // Prune expired tokens every day at midnight
    $schedule->command('sanctum:prune-expired --hours=24')->daily();
}

// Laravel 11 — routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();

Listing and Revoking Tokens

Token management in controllers
// List all tokens for the authenticated user
public function listTokens(Request $request)
{
    return $request->user()->tokens()->select([
        'id', 'name', 'last_used_at', 'expires_at', 'created_at'
    ])->get();
}

// Revoke a specific token by ID
public function revokeToken(Request $request, $tokenId)
{
    $request->user()->tokens()->where('id', $tokenId)->delete();

    return response()->json(['message' => 'Token revoked.']);
}

// Revoke all tokens except current (logout other devices)
public function revokeOtherTokens(Request $request)
{
    $currentTokenId = $request->user()->currentAccessToken()->id;

    $request->user()->tokens()
        ->where('id', '!=', $currentTokenId)
        ->delete();

    return response()->json(['message' => 'Other sessions terminated.']);
}

Advanced: Token Refresh Pattern

Sanctum doesn't have built-in refresh tokens like OAuth2, but you can implement a simple rotation pattern — when a token is about to expire, issue a new one and revoke the old one. This pattern is common in mobile apps.

app/Http/Controllers/Api/AuthController.php — refresh
/**
 * Refresh the current token: revoke old, issue new.
 */
public function refresh(Request $request)
{
    $user = $request->user();
    $oldToken = $user->currentAccessToken();
    $tokenName = $oldToken->name; // preserve device name

    // Delete old token
    $oldToken->delete();

    // Create fresh token
    $newToken = $user->createToken($tokenName)->plainTextToken;

    return response()->json([
        'access_token' => $newToken,
        'token_type'   => 'Bearer',
    ]);
}

Sanctum vs Passport vs Breeze — Quick Reference

Feature Sanctum Passport Breeze
OAuth2 support No Yes (full) No
API token auth Yes Yes No
SPA cookie auth Yes No No
Token abilities Yes (simple) Yes (full scopes) No
Token expiry Yes (config) Yes N/A
Setup complexity Low High Very Low
Best for Your own SPA/mobile Third-party OAuth clients Server-rendered apps

Testing Sanctum-Protected Routes

Laravel's testing utilities make it trivial to authenticate as a user with Sanctum for feature tests — no real token issuance required. Use Sanctum::actingAs() to simulate an authenticated request.

tests/Feature/AuthTest.php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_register(): void
    {
        $response = $this->postJson('/api/auth/register', [
            'name'                  => 'Test User',
            'email'                 => 'test@example.com',
            'password'              => 'password123',
            'password_confirmation' => 'password123',
        ]);

        $response->assertStatus(201)
                 ->assertJsonStructure([
                     'user' => ['id', 'name', 'email'],
                     'access_token',
                     'token_type',
                 ]);

        $this->assertDatabaseHas('users', ['email' => 'test@example.com']);
    }

    public function test_user_can_login(): void
    {
        $user = User::factory()->create(['password' => bcrypt('password123')]);

        $response = $this->postJson('/api/auth/login', [
            'email'    => $user->email,
            'password' => 'password123',
        ]);

        $response->assertOk()->assertJsonStructure(['access_token']);
    }

    public function test_authenticated_user_can_fetch_profile(): void
    {
        $user = User::factory()->create();

        // Act as this user with all abilities
        Sanctum::actingAs($user, ['*']);

        $response = $this->getJson('/api/user');

        $response->assertOk()
                 ->assertJson(['email' => $user->email]);
    }

    public function test_token_with_limited_ability_cannot_write(): void
    {
        $user = User::factory()->create();

        // Act as this user with read-only ability
        Sanctum::actingAs($user, ['posts:read']);

        $response = $this->postJson('/api/posts', ['title' => 'Test Post']);

        // Should be forbidden — token lacks posts:write
        $response->assertForbidden();
    }

    public function test_unauthenticated_request_returns_401(): void
    {
        $this->getJson('/api/user')->assertUnauthorized();
    }
}

Conclusion

Laravel Sanctum strikes the perfect balance between simplicity and security. For the majority of Laravel applications — whether you're building a mobile API or a React SPA — Sanctum covers everything you need:

The key architectural decision is which mode to use: if you own both the frontend and backend and they share a domain (or subdomain), go cookie-based. If you're issuing tokens to third-party clients or native mobile apps, go token-based. When in doubt — most projects need token auth for mobile and cookie auth for SPA — Sanctum handles both from the same package.

Laravel Sanctum Auth API Tokens SPA Auth Security Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.