Building Authentication with Laravel Sanctum
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.
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 Authentication: Issues personal access tokens (plain-text tokens stored as hashed values in the database). Suitable for mobile apps, CLI tools, or third-party clients.
- SPA Authentication: Uses Laravel's session-based cookie authentication. Your SPA makes a CSRF-cookie request first, then logs in. Subsequent requests are authenticated via the session cookie — no tokens to manage in localStorage.
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:
Install Sanctum
Require the package and publish its configuration and migration files.
# 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
Add HasApiTokens Trait
Add the HasApiTokens trait to your User model. This gives the model the ability to create and manage tokens.
<?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',
];
}
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).
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum', // <-- change from 'token'
'provider' => 'users',
],
],
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
php artisan make:controller Api/AuthController
<?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
<?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
# 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.
// 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;
// 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
'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()
))),
# 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:
'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:
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.
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;
}
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>
);
}
/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.
/*
* 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
// 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
// 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.
/**
* 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.
<?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:
- API Token Mode gives mobile apps and CLI tools secure, revocable, ability-scoped personal access tokens
- SPA Cookie Mode keeps credentials off the client entirely — no localStorage risks, just secure HttpOnly session cookies
- Token Expiry + Pruning keeps your
personal_access_tokenstable clean and sessions short-lived - Abilities let you scope what each token can do, enabling principle-of-least-privilege access control
- Testing utilities make auth-gated feature tests effortless with
Sanctum::actingAs()
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.
