Back
Laravel

Sanctum Authentication in Laravel: Modern Security

Master Laravel Sanctum to implement secure authentication for SPAs, APIs, and mobile applications. Learn about tokens, abilities, SPA cookie auth, CSRF protection, and security best practices.

Francisco ZapataWritten by Francisco Zapata
January 5, 202611 min read
Sanctum Authentication in Laravel: Modern Security

Authentication is one of the foundational pillars of any web application. Laravel Sanctum offers a lightweight yet powerful authentication system specifically designed for SPAs (Single Page Applications), mobile applications, and token-based APIs. Unlike more complex solutions like Passport, Sanctum focuses on simplicity without sacrificing security.

Installation and Setup

Starting with Laravel 11, Sanctum installation is handled by a single command that prepares everything you need.

php artisan install:api

This command publishes the Sanctum configuration file, creates the migration for the personal_access_tokens table, and sets up the routes/api.php file. Run the migration to create the table:

php artisan migrate

Next, add the HasApiTokens trait to your User model to enable token management.

<?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, HasFactory, Notifiable;

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

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

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Token-Based API Authentication

The most straightforward Sanctum approach is token-based authentication. Each user can generate multiple tokens with specific permissions, making it ideal for third-party integrations and mobile applications.

Registration and Login with Tokens

<?php

namespace App\Http\Controllers\Auth;

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
{
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'confirmed', 'min:8'],
            'device_name' => ['required', 'string'],
        ]);

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

        $token = $user->createToken($validated['device_name']);

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

    public function login(Request $request)
    {
        $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
            'device_name' => ['required', 'string'],
        ]);

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

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

        $token = $user->createToken($request->device_name);

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

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

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

Protecting Routes

// routes/api.php
use App\Http\Controllers\Auth\AuthController;

Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('posts', PostController::class);
});

Abilities: Granular Permission Control

Abilities let you assign specific permissions to each token, giving you precise control over what operations it can perform.

// Create token with specific abilities
$token = $user->createToken('admin-panel', [
    'posts:read',
    'posts:write',
    'users:read',
]);

// Create a read-only token
$readOnlyToken = $user->createToken('reporting', [
    'posts:read',
    'analytics:read',
]);

To check abilities within controllers or middleware:

// Inside a controller
public function update(Request $request, Post $post)
{
    if (! $request->user()->tokenCan('posts:write')) {
        abort(403, 'Token lacks write permissions.');
    }

    // Update the post...
}

Ability Middleware

Set up the middleware aliases in bootstrap/app.php for declarative permission checking on routes.

// bootstrap/app.php
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'abilities' => CheckAbilities::class,
        'ability' => CheckForAnyAbility::class,
    ]);
})

Apply the middleware to your routes:

// Requires ALL listed abilities
Route::get('/admin/dashboard', [AdminController::class, 'dashboard'])
    ->middleware(['auth:sanctum', 'abilities:admin:read,analytics:read']);

// Requires AT LEAST ONE of the listed abilities
Route::get('/reports', [ReportController::class, 'index'])
    ->middleware(['auth:sanctum', 'ability:reports:read,analytics:read']);

SPA Authentication with Cookies

For SPAs that live on the same domain (or subdomain) as your API, Sanctum offers session and cookie-based authentication, which is more secure than storing tokens on the frontend.

Backend Configuration

First, configure the first-party domains in config/sanctum.php:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
    'localhost,localhost:3000,localhost:5173,127.0.0.1'
)),

Enable the stateful middleware in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
})

Configure CORS in config/cors.php:

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
    'supports_credentials' => true,
];

Frontend Authentication Flow

// 1. Fetch the CSRF cookie
await axios.get('/sanctum/csrf-cookie');

// 2. Log in
const response = await axios.post('/login', {
    email: 'user@example.com',
    password: 'password',
});

// 3. Access protected routes (cookie is sent automatically)
const user = await axios.get('/api/user');

Make sure to configure Axios to send credentials:

// resources/js/bootstrap.js or your config file
import axios from 'axios';

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
axios.defaults.baseURL = 'http://localhost:8000';

Token Expiration and Revocation

Global Expiration

// config/sanctum.php
'expiration' => 60 * 24 * 7, // 7 days in minutes

Per-Token Expiration

$token = $user->createToken(
    'temporary-access',
    ['posts:read'],
    now()->addHours(24)
);

Revoking Tokens

// Revoke the current token
$request->user()->currentAccessToken()->delete();

// Revoke all user tokens
$user->tokens()->delete();

// Revoke a specific token
$user->tokens()->where('id', $tokenId)->delete();

// Revoke tokens by name
$user->tokens()->where('name', 'old-device')->delete();

Pruning Expired Tokens

Schedule automatic cleanup of expired tokens:

// routes/console.php or bootstrap/app.php
Schedule::command('sanctum:prune-expired --hours=24')->daily();

Mobile Application Authentication

The mobile authentication flow relies on Bearer tokens sent in the Authorization header with every request.

// Mobile login endpoint
Route::post('/mobile/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

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

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

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

    return response()->json([
        'token' => $user->createToken($request->device_name)->plainTextToken,
    ]);
});

Testing with Sanctum

Sanctum provides dedicated helpers that simplify writing authentication tests.

<?php

namespace Tests\Feature;

use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AuthTest extends TestCase
{
    public function test_authenticated_user_can_access_profile(): void
    {
        Sanctum::actingAs(
            User::factory()->create(),
            ['user:read']
        );

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

        $response->assertOk()
            ->assertJsonStructure(['id', 'name', 'email']);
    }

    public function test_token_without_ability_is_rejected(): void
    {
        Sanctum::actingAs(
            User::factory()->create(),
            ['posts:read'] // No write permission
        );

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

        $response->assertForbidden();
    }

    public function test_unauthenticated_access_is_rejected(): void
    {
        $response = $this->getJson('/api/user');

        $response->assertUnauthorized();
    }
}

Conclusion

Laravel Sanctum is the ideal solution when you need flexible authentication without the complexity of OAuth. Its dual approach, tokens for APIs and mobile apps alongside cookies for SPAs, covers the most common scenarios in modern development. Abilities provide granular permission control, and the seamless integration with Laravel's testing ecosystem makes it straightforward to verify your application's security. Implementing these practices from the start of your project will save you headaches as the application scales.

Comments (0)

Leave a comment

Be the first to comment