Laravel Authentication System—Sanctum
Sanctum is Laravel's "gatekeeper"—it handles session-based authentication for web pages and token-based authentication for APIs, providing two authentication systems within a single framework.
1. What You'll Learn
- Laravel Sanctum Installation and Configuration: SPA Authentication + Token Authentication
- Registration/Login/Logout Process: Dual-Channel Approach (Web Session + API Token)
- Token Capabilities and Permissions: Granular Control via the "abilities" Field
- Multi-tenant authentication isolation: Tenant-aware middleware and scope
- Password Reset and Email Verification Process
2. A True Story of a Security Engineer
(1) Pain Point: Security Incidents Caused by Confusing API Authentication Schemes
Bob used JWT for the ShopMetrics API and sessions for the web—two separate authentication systems, with user login states not synchronized. After Alice logged in via her browser, she still had to obtain a separate token for API calls, which frequently expired, causing operations to be interrupted. Even more seriously, Charlie discovered that a single API token could access data from all tenants—there was no tenant isolation, posing a significant risk of cross-tenant data leaks.
(2) Solution to "Sanctum"
Sanctum unifies Web and API authentication—in SPA mode, the browser and API share a session; in token mode, it provides API tokens to third-party clients; and the abilities field allows for fine-grained control of permissions.
// SPA auth — share session between web and API
// Just ensure Sanctum's middleware is on API routes
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// Token auth — for third-party clients
$token = $user->createToken('api-token', ['read-orders', 'write-products']);
// Token can only do what its abilities allow
(3) Revenue
After Bob's single sign-on, Alice's SPA experience is seamless and transparent, Charlie's token permissions are precisely restricted to "read orders only," and the risk of cross-tenant data leaks is reduced to zero.
3. Sanctum Installation and Configuration
(1) Installation
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
# Creates: personal_access_tokens table
(2) Configuration
// config/sanctum.php
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : '',
))),
'expiration' => null, // Token never expires (or set minutes)
(3) SPA Front-End Configuration
// resources/js/bootstrap.js
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.withCredentials = true; // Send cookies with API requests
| Configuration Option | Function | Default Value |
|---|---|---|
stateful |
SPA-certified domain | localhost:3000, etc. |
expiration |
Token expiration time | null (never expires) |
guard |
Verified Guardian | web |
(1) ▶ Example: Sanctum SPA Authentication Configuration
# .env — Configure SPA domains
SESSION_DOMAIN=localhost
SANCTUM_STATEFUL_DOMAINS=localhost:3000,localhost:5173
SESSION_DRIVER=redis
# Install Sanctum and configure
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Output:
# Command executed successfully
4. Registration/Login/Logout Process
(1) Registration
// app/Http/Controllers/Auth/RegisterController.php
class RegisterController extends Controller
{
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'tenant_id' => $request->tenant_id,
'role' => 'tenant_owner',
]);
event(new Registered($user));
Auth::login($user);
return response()->json([
'user' => $user,
'message' => 'Registration successful.',
], 201);
}
}
(2) Log In
// app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
if (!Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$request->session()->regenerate();
return response()->json([
'user' => Auth::user(),
'message' => 'Login successful.',
]);
}
public function logout(Request $request): JsonResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out.']);
}
}
(3) Token-Based Authentication and Login
// app/Http/Controllers/Auth/ApiTokenController.php
class ApiTokenController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
$token = $user->createToken(
$request->device_name ?? 'api-token',
$request->abilities ?? ['*'],
);
return response()->json([
'user' => $user,
'token' => $token->plainTextToken,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
| Mode | Login Method | State Persistence | Suitable Scenarios |
|---|---|---|---|
| SPA Session | Form Login | Cookies/Sessions | SPA Front End |
| Token | Get plainTextToken | Token string | Mobile/Third-party |
(1) ▶ Example: ShopMetrics Dual-Mode Authentication Routing
// routes/web.php — Session auth for SPA
Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LogoutController::class, 'logout'])->middleware('auth');
Route::post('/register', [RegisterController::class, 'register']);
// routes/api.php — Token auth for API
Route::post('/tokens', [ApiTokenController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::delete('/tokens/current', [ApiTokenController::class, 'logout']);
Route::get('/user', fn (Request $request) => $request->user());
});
Output:
// Execution Successful
5. Token Capabilities and Permissions
(1) Create a Token with abilities
// Create token with specific abilities
$token = $user->createToken('analytics-read', [
'read-analytics',
'read-orders',
]);
// Full access token
$token = $user->createToken('admin-token', ['*']);
// Token with expiry
$token = $user->createToken('temp-token', ['read-orders'], now()->addDays(7));
(2) Check Token Capabilities
// In controller or middleware
if ($request->user()->tokenCan('read-analytics')) {
return AnalyticsResource::collection($analytics);
}
// In middleware
class CheckAbilities
{
public function handle($request, $next, ...$abilities)
{
foreach ($abilities as $ability) {
if (!$request->user()->tokenCan($ability)) {
abort(403, 'Insufficient permissions.');
}
}
return $next($request);
}
}
(3) Abilities Permissions Matrix
| Role | Abilities | Description |
|---|---|---|
| Super Admin | * |
Full Permissions |
| Tenant Owner | read-*, write-* |
Full permissions within the tenant |
| Analyst | read-analytics, read-orders |
Read-only |
| API Client | read-products, write-orders |
Third-party only |
(1) ▶ Example: ShopMetrics Token Access Control
// app/Http/Controllers/Api/TokenController.php
class TokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'abilities' => 'sometimes|array',
'abilities.*' => 'in:read-analytics,read-orders,write-orders,read-products,write-products',
'expires_in_days' => 'sometimes|integer|min:1|max:365',
]);
$abilities = $validated['abilities'] ?? match ($request->user()->role) {
'super_admin' => ['*'],
'tenant_owner' => ['read-analytics', 'read-orders', 'write-orders', 'read-products', 'write-products'],
'analyst' => ['read-analytics', 'read-orders', 'read-products'],
default => [],
};
$expiresAt = isset($validated['expires_in_days'])
? now()->addDays($validated['expires_in_days'])
: null;
$token = $request->user()->createToken(
$validated['name'],
$abilities,
$expiresAt,
);
return response()->json([
'token' => $token->plainTextToken,
'abilities' => $abilities,
], 201);
}
}
Output:
// Execution Successful
6. Multi-tenant Authentication Isolation
(1) Sanctum Token Authentication Sequence Diagram
sequenceDiagram
participant C as Client
participant S as Sanctum
participant DB as Database
participant M as Middleware
C->>S: POST /api/tokens (email + password)
S->>DB: Find user + verify password
DB-->>S: User found
S->>DB: Create personal_access_token
DB-->>S: Token created
S-->>C: Return plainTextToken
C->>M: GET /api/shops (Bearer token)
M->>DB: Find token in personal_access_tokens
DB-->>M: Token + user + abilities
M->>M: Check tokenCan() ability
M->>M: Check tenant scope
M-->>C: Return tenant-scoped data
(2) Tenant Authentication Middleware
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user || !$user->tenant_id) {
abort(403, 'No tenant associated.');
}
$tenant = $user->tenant;
if ($tenant->status !== 'active') {
abort(403, 'Tenant account is suspended.');
}
// Set tenant context for all queries
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
(3) Multi-tenant Scope
// app/Models/TenantScope.php
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenant = app()->make(Tenant::class)) {
$builder->where($model->getTable() . '.tenant_id', $tenant->id);
}
}
}
// Apply to models
class Shop extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
(1) ▶ Example: ShopMetrics Multitenant Authentication Routing
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
// All API routes require tenant resolution
Route::middleware('tenant.resolve')->prefix('v1')->group(function () {
Route::apiResource('shops', Api\ShopController::class);
Route::apiResource('products', Api\ProductController::class);
Route::apiResource('orders', Api\OrderController::class)
->only(['index', 'show', 'update']);
// Analytics — requires specific ability
Route::get('analytics', [Api\AnalyticsController::class, 'index'])
->middleware('ability:read-analytics');
// Admin-only routes
Route::middleware('ability:*')->prefix('admin')->group(function () {
Route::apiResource('plans', Api\Admin\PlanController::class);
Route::apiResource('tenants', Api\Admin\TenantController::class);
});
});
});
Output:
// Execution Successful
7. Password Reset and Email Verification
(1) Password Reset
// routes/web.php
Route::post('/forgot-password', [PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [PasswordResetController::class, 'reset']);
// app/Http/Controllers/PasswordResetController.php
class PasswordResetController extends Controller
{
public function sendResetLink(Request $request): JsonResponse
{
$request->validate(['email' => 'required|email|exists:users']);
$status = Password::sendResetLink($request->only('email'));
return response()->json([
'message' => $status === Password::RESET_LINK_SENT
? 'Reset link sent to your email.'
: 'Unable to send reset link.',
]);
}
}
(2) Email Verification
// Model must implement MustVerifyEmail
class User extends Model implements MustVerifyEmail
{
use Notifiable, VerifiesEmails;
}
// Protected routes — only verified users
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
});
// API email verification
Route::middleware('auth:sanctum')->group(function () {
Route::post('/email/verification-notification', function (Request $request) {
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Verification link sent.']);
});
});
(1) ▶ Example: Complete Configuration of the ShopMetrics Authentication Process
// bootstrap/app.php — Configure auth middleware
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant.resolve' => TenantResolve::class,
'ability' => CheckAbilities::class,
]);
})
// User model with verification
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, Notifiable;
protected $fillable = [
'tenant_id', 'name', 'email', 'password', 'role', 'email_verified_at',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
Output:
// Execution Successful
8. Comprehensive Example: ShopMetrics Complete Authentication System
// ============================================
// Comprehensive: ShopMetrics Auth System
// Covers: SPA auth, token auth, abilities, tenant isolation
// ============================================
// routes/api.php — Complete API auth routes
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::post('/forgot-password', [Auth\PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [Auth\PasswordResetController::class, 'reset']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => $r->user()->load('tenant'));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
Route::post('/email/verification', function (Request $request) {
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Verification email sent.']);
});
Route::post('/tokens', [Auth\TokenController::class, 'store']);
Route::get('/tokens', fn (Request $r) => $r->user()->tokens);
Route::delete('/tokens/{id}', fn (Request $r, $id) => $r->user()->tokens()->where('id', $id)->delete());
});
});
// Protected API routes
Route::middleware(['auth:sanctum', 'verified', 'tenant.resolve'])
->prefix('v1')->group(function () {
Route::apiResource('shops', Api\ShopController::class);
Route::apiResource('products', Api\ProductController::class);
Route::apiResource('orders', Api\OrderController::class)->only(['index', 'show', 'update']);
Route::get('analytics/overview', [Api\AnalyticsController::class, 'overview'])
->middleware('ability:read-analytics');
Route::post('reports/generate', [Api\ReportController::class, 'generate'])
->middleware('ability:write-reports');
});
❓ FAQ
withCredentials: true, and Sanctum automatically handles CSRF and sessions.expiration (in minutes) in config/sanctum.php, or pass the third parameter, $user->createToken('name', ['*'], now()->addDays(30)), when creating the token. Expired tokens are automatically invalidated.$user->tokens()->delete() revokes all of the user's tokens; $user->currentAccessToken()->delete() revokes only the current token.📖 Summary
- Sanctum unifies the two authentication mechanisms: Web Session and API Token
- SPA mode uses cookies/sessions for authentication, eliminating the need to manage tokens
- The Token mode uses
plainTextTokento provide API access to third-party clients - Token abilities: Fine-tune permissions to avoid granting excessive access
- TenantResolve middleware + TenantScope to implement multi-tenant authentication isolation
- Email verification and password reset are ready to use right out of the box
📝 Exercises
-
Basic Exercise (⭐): Configure Sanctum SPA authentication, implement three API endpoints for registration, login, and logout, and use Postman to test the session authentication process.
-
Advanced Exercise (⭐⭐): Implement the token-based authentication model, create a token with abilities, and write a middleware to check
tokenCan()permissions to ensure that the "analyst" role can only read data. -
Challenge (⭐⭐⭐): Implement multi-tenant isolation using the TenantScope and TenantResolve middleware to ensure that API tokens can only access data from their own tenant, and that cross-tenant requests return a 403 error.



