Backend

Building Authentication with Laravel Sanctum

Mayur Dabhi
Mayur Dabhi
April 16, 2026
14 min read

You've got a Laravel API, a React frontend, and maybe a mobile app on the roadmap. The login works in Postman, but the SPA keeps getting a 419 or a 401, and you're not sure whether you should be storing a token in localStorage or relying on cookies. Most of the confusion comes from the fact that Sanctum does two different jobs, and the docs cover both on the same page.

This post walks through both of Sanctum's modes: personal access tokens for mobile apps and API clients, and cookie-based session auth for your own SPA. We'll build the controllers, routes, token abilities, expiry, and tests, and I'll point out the spots where things usually break.

Sanctum vs Passport

Sanctum fits when you control both the API and the client that calls it (your own React or Vue SPA, your own mobile app). If third-party apps need to get tokens on behalf of your users through an OAuth2 flow, that's Passport's job. For most first-party apps Sanctum is enough, and it's a lot less to set up and reason about.

How Laravel Sanctum Works

Sanctum has two modes, and which one you use depends on who the client is:

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

On Laravel 11 and later, php artisan install:api installs Sanctum, publishes its migration, and creates routes/api.php for you. On older projects, install it with Composer:

1

Install Sanctum

Require the package and publish its configuration and migration files.

Terminal
# Install Sanctum (on Laravel 11+, run php artisan install:api instead)
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. Strictly speaking this is optional: auth:sanctum uses the guard Sanctum registers itself. Setting it just keeps auth('api') consistent.

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

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

Laravel 11 trimmed down the default config files, so you may not have a config/auth.php to edit. Publish it with php artisan config:publish auth if you need to change guards.

API Token Authentication

Token mode is the simpler of the two. The user logs in, gets a plain-text token back, and sends it as a Bearer header on every request after that. Sanctum stores only a SHA-256 hash of the token, so the plain text is shown exactly once, at creation. If the client loses it, issue a new one.

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"
Don't drop the Accept header

If you call a protected route without Accept: application/json, Laravel treats the 401 as a browser request and tries to redirect to a route named login. On an API-only app that route doesn't exist, so instead of a clean 401 you get a 500 with Route [login] not defined. Every API client (and every test) should send that header, or use postJson()/getJson() in tests.

Token Abilities (Scopes)

You can attach abilities to a token, which work a lot like OAuth scopes. A typical setup gives the mobile app a read-only token and the admin dashboard a full-access one.

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

One gotcha: the ability and abilities middleware aren't registered for you. In Laravel 11 add the aliases in bootstrap/app.php (CheckForAnyAbility and CheckAbilities from Laravel\Sanctum\Http\Middleware); in Laravel 10 add them to $middlewareAliases in the HTTP kernel. Without that you'll see Target class [ability] does not exist.

SPA Authentication (Cookie-Based)

If your SPA (React, Vue, Angular) sits on the same top-level domain as the Laravel backend, use cookies instead of tokens. Anything in localStorage can be read by any script that runs on the page, so a single XSS bug leaks the token. An HttpOnly session cookie can't be read from JavaScript at all, and Sanctum's SPA mode reuses Laravel's existing 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 order matters here. Fetch the CSRF cookie, post the credentials, and from then on the browser sends the session cookie with every request on its own.

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 usually only need to call it once per session, before the first login or register request.

If you get 419 CSRF token mismatch or a 401 on /api/user right after a successful login, check three things first: the SPA's exact host and port are in SANCTUM_STATEFUL_DOMAINS, SESSION_DOMAIN covers both hosts, and config/cors.php has supports_credentials set to true. In my experience one of those is almost always the cause. Open the browser's network tab and confirm the laravel_session cookie is actually being sent.

Token Expiration and Management

Out of the box, Sanctum tokens never expire. Set an expiration for anything that matters, and schedule the prune command so expired rows don't pile up in 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 has no refresh tokens the way OAuth2 does. What you can do is rotate: when a token is close to expiring, the client calls an endpoint that issues a new one and deletes the old one. Mobile apps use this a lot. Keep in mind it only works while the old token is still valid, so the client has to refresh before expiry, not after.

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

In feature tests you don't need to issue real tokens. Sanctum::actingAs() authenticates the request as a given user, and its second argument sets the token's abilities, so you can test the forbidden paths too.

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();
    }
}

When I'd use Sanctum (and when I wouldn't)

The main decision is which mode to use. If you own the frontend and backend and they share a domain or subdomain, I'd go with cookies. For native mobile apps, CLI tools, or server-to-server calls, use tokens. Plenty of projects end up needing both, and Sanctum handles that from the same package and the same auth:sanctum middleware.

I wouldn't use Sanctum in these cases:

Before going to production, double-check that token expiration is set, the prune command is scheduled, SANCTUM_STATEFUL_DOMAINS lists your real production host rather than localhost, and the login route is rate limited.

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.