Building Authentication with Laravel Sanctum
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 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 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. After that, the session cookie authenticates every request, so there's no token sitting in localStorage.
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:
Install Sanctum
Require the package and publish its configuration and migration files.
# 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
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. Strictly speaking this is optional: auth:sanctum uses the guard Sanctum registers itself. Setting it just keeps auth('api') consistent.
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum', // <-- change from 'token'
'provider' => 'users',
],
],
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
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"
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.
// 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 |
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
'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 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.
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 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.
/*
* 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 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.
/**
* 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.
<?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:
- Third-party clients need delegated access. If other companies' apps should be able to act on behalf of your users with a consent screen, you need OAuth2. Use Passport.
- The SPA is on a completely different domain. Cookie mode depends on the SPA and API sharing a top-level domain. If they don't, you're stuck with token mode, and then you have to decide where the token lives in the browser.
- You need stateless tokens other services can verify. Sanctum tokens are checked against the database on every request. If several services need to validate tokens without hitting your Laravel database, look at JWTs or a proper identity provider.
- It's a plain server-rendered app. Blade with Breeze or Fortify and the normal
webguard is all you need. Adding Sanctum there is extra moving parts for nothing.
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.