Laravel Sanctum
Library
Laravel Sanctum
Overview
Laravel Sanctum is a lightweight authentication system for SPAs (single page applications), mobile applications, and simple token-based APIs. As of 2025, it has established itself as the standard choice for API authentication in the Laravel ecosystem. By eliminating OAuth complexity while supporting both SPA session authentication and API token authentication through dual functionality, it provides developers with flexibility and simplicity. It's a comprehensive security solution that integrates CSRF protection, session authentication, and defense against XSS attacks.
Details
Laravel Sanctum 2025 is fully integrated with Laravel 12.x, enabling easy installation with the php artisan install:api command. It adopts a dual authentication mode that uses Laravel's built-in cookie-based session authentication for SPAs and provides API tokens for third-party clients (mobile apps). It automatically determines the request origin and applies cookie authentication for SPA requests and Authorization header token authentication for API requests. Tokens can define scopes through a concept called abilities, enabling fine-grained permission control. It also supports token expiration settings and automatic deletion scheduling.
Key Features
- Dual Authentication Mode: Integrates SPA session authentication and mobile API token authentication
- Easy Setup: Instant project preparation with
install:apicommand - CSRF Protection: Comprehensive cross-site request forgery protection for SPAs
- Token Ability Management: OAuth2 scope-like fine-grained permission control
- Automatic Authentication Detection: Automatic authentication method selection based on request origin
- Laravel Integration: Complete integration with Eloquent models, middleware, and commands
Pros and Cons
Pros
- Easy implementation of API token authentication without OAuth complexity
- Flexible architecture supporting both SPAs and mobile applications
- Complete integration with Laravel ecosystem minimizes learning costs
- High security level with CSRF protection and XSS attack defense
- Enhanced user experience through fine-grained permission management with personal access tokens
- Full compatibility with Laravel starter kits like Breeze and Jetstream
Cons
- Laravel-specific, cannot be used outside PHP frameworks
- Limited functionality in large-scale microservice environments
- Limited ecosystem compared to standardized protocols like OAuth2
- Difficult to handle complex enterprise authentication requirements (SAML, LDAP)
- Token refresh functionality not provided by default
- Multi-tenant authentication and role-based access control require separate implementation
Reference Pages
Code Examples
Basic Setup and Installation
# Install Laravel Sanctum
php artisan install:api
# Run database migrations
php artisan migrate
# Publish Sanctum config file (optional)
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
# Schedule periodic cleanup of expired tokens
php artisan schedule:work
API Token Setup in User Model
<?php
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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 $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
// Define token abilities
public function getAllowedAbilities(): array
{
return [
'read-posts',
'create-posts',
'update-posts',
'delete-posts',
'manage-users'
];
}
// Admin permission check
public function isAdmin(): bool
{
return $this->email === '[email protected]';
}
}
SPA Authentication Setup (Frontend-Backend Integration)
// config/sanctum.php configuration
<?php
return [
// Stateful (SPA) domain configuration
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
// Guard when using sessions
'guard' => ['web'],
// Token expiration (minutes, null for no expiration)
'expiration' => null,
// Middleware configuration
'middleware' => [
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
],
];
// Additional configuration in app/Http/Kernel.php
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
protected $middlewareGroups = [
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
}
// SPA authentication routes (routes/web.php)
use App\Http\Controllers\Auth\AuthController;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum');
// CSRF protection endpoint
Route::get('/sanctum/csrf-cookie', function () {
return response()->json(['message' => 'CSRF cookie set']);
});
Mobile App API Token Authentication
// app/Http/Controllers/Auth/TokenController.php
<?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 TokenController extends Controller
{
// Issue token (login)
public function issueToken(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' => ['The provided credentials are incorrect.'],
]);
}
// Create token with abilities
$abilities = $user->isAdmin()
? ['*'] // Admin has all permissions
: ['read-posts', 'create-posts', 'update-posts'];
$token = $user->createToken(
$request->device_name,
$abilities,
now()->addWeek() // 1 week expiration
)->plainTextToken;
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
'user' => $user,
'abilities' => $abilities
]);
}
// Revoke token (logout)
public function revokeToken(Request $request)
{
// Revoke current token
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logged out successfully'
]);
}
// Revoke all tokens
public function revokeAllTokens(Request $request)
{
// Revoke all tokens
$request->user()->tokens()->delete();
return response()->json([
'message' => 'Logged out from all devices'
]);
}
// List user tokens
public function listTokens(Request $request)
{
$tokens = $request->user()->tokens()->get();
return response()->json([
'tokens' => $tokens->map(function ($token) {
return [
'id' => $token->id,
'name' => $token->name,
'abilities' => $token->abilities,
'last_used_at' => $token->last_used_at,
'created_at' => $token->created_at,
'expires_at' => $token->expires_at,
];
})
]);
}
// Delete specific token
public function revokeSpecificToken(Request $request, $tokenId)
{
$request->user()->tokens()->where('id', $tokenId)->delete();
return response()->json([
'message' => 'Token revoked successfully'
]);
}
}
// routes/api.php
use App\Http\Controllers\Auth\TokenController;
Route::post('/sanctum/token', [TokenController::class, 'issueToken']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/sanctum/revoke', [TokenController::class, 'revokeToken']);
Route::post('/sanctum/revoke-all', [TokenController::class, 'revokeAllTokens']);
Route::get('/sanctum/tokens', [TokenController::class, 'listTokens']);
Route::delete('/sanctum/tokens/{tokenId}', [TokenController::class, 'revokeSpecificToken']);
});
Access Control with Abilities
// app/Http/Controllers/PostController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
// Check read permission
if ($request->user()->tokenCant('read-posts')) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$posts = Post::paginate(10);
return response()->json($posts);
}
public function store(Request $request)
{
// Check create permission
if ($request->user()->tokenCant('create-posts')) {
return response()->json(['error' => 'Insufficient permissions'], 403);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$post = $request->user()->posts()->create($validated);
return response()->json($post, 201);
}
public function update(Request $request, Post $post)
{
// Check update permission and ownership
if ($request->user()->tokenCant('update-posts') ||
$post->user_id !== $request->user()->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'content' => 'sometimes|required|string',
]);
$post->update($validated);
return response()->json($post);
}
public function destroy(Request $request, Post $post)
{
// Check delete permission (admin or post owner)
$canDelete = $request->user()->tokenCan('delete-posts') ||
($post->user_id === $request->user()->id && $request->user()->tokenCan('update-posts'));
if (!$canDelete) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$post->delete();
return response()->json(['message' => 'Post deleted successfully']);
}
}
// Using ability middleware in routes/api.php
use App\Http\Controllers\PostController;
Route::middleware('auth:sanctum')->group(function () {
// Multiple abilities required (AND condition)
Route::get('/admin/users', function () {
return response()->json(['users' => User::all()]);
})->middleware('abilities:manage-users,read-posts');
// Any of the abilities required (OR condition)
Route::get('/posts', [PostController::class, 'index'])
->middleware('ability:read-posts,admin');
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
React SPA Sanctum Authentication Implementation
// frontend/src/api/auth.js
import axios from 'axios'
// Axios configuration
axios.defaults.withCredentials = true
axios.defaults.withXSRFToken = true
const API_BASE_URL = 'http://localhost:8000'
// Get CSRF cookie
export const getCsrfCookie = async () => {
await axios.get(`${API_BASE_URL}/sanctum/csrf-cookie`)
}
// Login
export const login = async (email, password) => {
await getCsrfCookie()
const response = await axios.post(`${API_BASE_URL}/login`, {
email,
password
})
return response.data
}
// Logout
export const logout = async () => {
const response = await axios.post(`${API_BASE_URL}/logout`)
return response.data
}
// Get user info
export const getUser = async () => {
const response = await axios.get(`${API_BASE_URL}/user`)
return response.data
}
// Get posts
export const getPosts = async () => {
const response = await axios.get(`${API_BASE_URL}/api/posts`)
return response.data
}
// Create post
export const createPost = async (postData) => {
const response = await axios.post(`${API_BASE_URL}/api/posts`, postData)
return response.data
}
// frontend/src/hooks/useAuth.js
import { useState, useEffect, createContext, useContext } from 'react'
import * as authAPI from '../api/auth'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const userData = await authAPI.getUser()
setUser(userData)
} catch (error) {
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (email, password) => {
try {
await authAPI.login(email, password)
await checkAuth()
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.message || 'Login failed'
}
}
}
const logout = async () => {
try {
await authAPI.logout()
setUser(null)
} catch (error) {
console.error('Logout error:', error)
}
}
const value = {
user,
login,
logout,
loading,
isAuthenticated: !!user
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
// frontend/src/components/LoginForm.jsx
import React, { useState } from 'react'
import { useAuth } from '../hooks/useAuth'
export const LoginForm = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
const result = await login(email, password)
if (!result.success) {
setError(result.error)
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit} className="login-form">
<h2>Login</h2>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
)
}
Mobile App (React Native) Implementation Example
// mobile/src/services/authService.js
import AsyncStorage from '@react-native-async-storage/async-storage'
const API_BASE_URL = 'https://api.example.com'
class AuthService {
constructor() {
this.token = null
}
// Load token from storage
async loadToken() {
try {
this.token = await AsyncStorage.getItem('access_token')
return this.token
} catch (error) {
console.error('Failed to load token:', error)
return null
}
}
// Save token to storage
async saveToken(token) {
try {
this.token = token
await AsyncStorage.setItem('access_token', token)
} catch (error) {
console.error('Failed to save token:', error)
}
}
// Remove token
async removeToken() {
try {
this.token = null
await AsyncStorage.removeItem('access_token')
} catch (error) {
console.error('Failed to remove token:', error)
}
}
// Login
async login(email, password, deviceName) {
try {
const response = await fetch(`${API_BASE_URL}/sanctum/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
device_name: deviceName
})
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Login failed')
}
const data = await response.json()
await this.saveToken(data.access_token)
return { success: true, user: data.user }
} catch (error) {
return { success: false, error: error.message }
}
}
// Logout
async logout() {
try {
if (this.token) {
await fetch(`${API_BASE_URL}/api/sanctum/revoke`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
}
})
}
} catch (error) {
console.error('Logout error:', error)
} finally {
await this.removeToken()
}
}
// API request
async apiRequest(endpoint, options = {}) {
await this.loadToken()
const config = {
headers: {
'Content-Type': 'application/json',
...(this.token && { 'Authorization': `Bearer ${this.token}` }),
...options.headers
},
...options
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, config)
if (response.status === 401) {
await this.removeToken()
throw new Error('Authentication expired. Please login again.')
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Request failed')
}
return response.json()
}
// Get user info
async getUser() {
return this.apiRequest('/api/user')
}
// Get posts
async getPosts() {
return this.apiRequest('/api/posts')
}
// Create post
async createPost(postData) {
return this.apiRequest('/api/posts', {
method: 'POST',
body: JSON.stringify(postData)
})
}
}
export default new AuthService()
// mobile/src/hooks/useAuth.js
import React, { createContext, useContext, useEffect, useState } from 'react'
import authService from '../services/authService'
import { Platform } from 'react-native'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
try {
const userData = await authService.getUser()
setUser(userData)
} catch (error) {
setUser(null)
} finally {
setLoading(false)
}
}
const login = async (email, password) => {
const deviceName = `${Platform.OS}-${Platform.Version}`
const result = await authService.login(email, password, deviceName)
if (result.success) {
setUser(result.user)
}
return result
}
const logout = async () => {
await authService.logout()
setUser(null)
}
const value = {
user,
login,
logout,
loading,
isAuthenticated: !!user,
apiRequest: authService.apiRequest.bind(authService)
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
Token Management and Security Optimization
// app/Console/Commands/PruneExpiredTokens.php
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Laravel\Sanctum\PersonalAccessToken;
class PruneExpiredTokens extends Command
{
protected $signature = 'tokens:prune {--hours=24}';
protected $description = 'Prune expired tokens older than specified hours';
public function handle()
{
$hours = $this->option('hours');
$deletedCount = PersonalAccessToken::where('expires_at', '<', now())
->where('expires_at', '<', now()->subHours($hours))
->delete();
$this->info("Deleted {$deletedCount} expired tokens older than {$hours} hours.");
}
}
// Scheduled execution in app/Console/Kernel.php
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule)
{
// Delete tokens daily at midnight
$schedule->command('sanctum:prune-expired --hours=24')->daily();
// Custom command execution
$schedule->command('tokens:prune --hours=72')->weekly();
}
}
// Custom Personal Access Token model
<?php
// app/Models/PersonalAccessToken.php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class PersonalAccessToken extends SanctumPersonalAccessToken
{
protected $fillable = [
'name',
'token',
'abilities',
'expires_at',
'last_used_at',
];
protected $casts = [
'abilities' => 'json',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// Add security features
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function isInactive(int $days = 30): bool
{
return $this->last_used_at && $this->last_used_at->diffInDays(now()) > $days;
}
// Add login source information
public function scopeActiveTokens($query)
{
return $query->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeInactiveTokens($query, int $days = 30)
{
return $query->where('last_used_at', '<', now()->subDays($days));
}
}
// Use custom model in config/sanctum.php
return [
'personal_access_token_model' => App\Models\PersonalAccessToken::class,
// Other configurations...
];