Sanctum решает две принципиально разные задачи одним пакетом, и именно это чаще всего сбивает с толку: SPA-аутентификация через куки и API-токены для мобильных приложений — не варианты одного механизма, а разные механизмы с разными требованиями к настройке.
Разберём оба и, отдельно, где они ломаются: почти все проблемы с Sanctum — это проблемы с доменами, куками и CORS, а не с самим пакетом.
Sanctum vs Passport
| Критерий | Sanctum | Passport |
|---|---|---|
| Сложность | Простой | Сложный |
| OAuth 2.0 | Нет | Да |
| Для SPA | Идеален | Избыточен |
| Для мобильных | Да | Да |
| Third-party OAuth | Нет | Да |
Sanctum — для своих приложений (SPA, mobile). Passport — когда нужен полноценный OAuth 2.0 сервер для третьих сторон.
Установка
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}Режим 1: SPA Authentication (cookies)
Идеален для SPA на том же домене или поддомене.
Настройка CORS
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
'supports_credentials' => true, // Важно!
];Настройка Sanctum
// config/sanctum.php
return [
'stateful' => explode(',', env(
'SANCTUM_STATEFUL_DOMAINS',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000'
)),
];# .env
SESSION_DOMAIN=.myapp.com
SANCTUM_STATEFUL_DOMAINS=myapp.com,api.myapp.com
FRONTEND_URL=https://myapp.comAPI routes
// routes/api.php
use App\Http\Controllers\AuthController;
// Публичные
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Защищённые
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [AuthController::class, 'user']);
Route::post('/logout', [AuthController::class, 'logout']);
});Контроллер
// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
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|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
Auth::login($user);
return response()->json([
'user' => $user,
], 201);
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
throw ValidationException::withMessages([
'email' => ['Неверный email или пароль.'],
]);
}
$request->session()->regenerate();
return response()->json([
'user' => Auth::user(),
]);
}
public function user(Request $request)
{
return response()->json([
'user' => $request->user(),
]);
}
public function logout(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out']);
}
}Frontend (Next.js)
// lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL;
async function getCsrfToken() {
await fetch(`${API_URL}/sanctum/csrf-cookie`, {
credentials: 'include',
});
}
export async function login(email: string, password: string) {
await getCsrfToken();
const response = await fetch(`${API_URL}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
return response.json();
}
export async function getUser() {
const response = await fetch(`${API_URL}/api/user`, {
credentials: 'include',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('Not authenticated');
}
return response.json();
}
export async function logout() {
await fetch(`${API_URL}/api/logout`, {
method: 'POST',
credentials: 'include',
});
}Cookie-режим требует, чтобы фронтенд и API были на одном домене верхнего уровня. app.site.ru и api.site.ru — работает, при правильно заданном SESSION_DOMAIN=.site.ru. myapp.vercel.app и api.site.ru — не работает и работать не будет: браузеры блокируют сторонние куки, и никакая настройка CORS этого не изменит.
Это первое, что нужно проверить при выборе режима, потому что обнаружить ограничение после написания фронтенда — значит переделывать авторизацию целиком. Разные домены верхнего уровня — это режим токенов, а не куки.
Режим 2: API Tokens (для мобильных)
Для мобильных приложений или когда cookies не подходят.
Выдача токена
// app/Http/Controllers/AuthController.php
public function createToken(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
'device_name' => 'required|string',
]);
$user = User::where('email', $credentials['email'])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['Неверные учётные данные.'],
]);
}
$token = $user->createToken($credentials['device_name']);
return response()->json([
'user' => $user,
'token' => $token->plainTextToken,
]);
}
public function revokeToken(Request $request)
{
// Удалить текущий токен
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked']);
}
public function revokeAllTokens(Request $request)
{
// Удалить все токены пользователя
$request->user()->tokens()->delete();
return response()->json(['message' => 'All tokens revoked']);
}Использование токена
curl -X GET https://api.example.com/api/user \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Accept: application/json"Token Abilities (права)
// Создание токена с ограниченными правами
$token = $user->createToken('mobile', ['read:orders', 'create:orders']);
// Проверка прав в middleware
Route::get('/orders', function () {
// ...
})->middleware('auth:sanctum', 'abilities:read:orders');
// Или в контроллере
public function index(Request $request)
{
if (!$request->user()->tokenCan('read:orders')) {
abort(403);
}
}Token Expiration
// config/sanctum.php
return [
'expiration' => 60 * 24 * 7, // 7 дней в минутах
];// Ручная проверка и обновление
public function refreshToken(Request $request)
{
$user = $request->user();
// Удаляем текущий токен
$user->currentAccessToken()->delete();
// Создаём новый
$token = $user->createToken($request->device_name);
return response()->json([
'token' => $token->plainTextToken,
]);
}Токен показывается один раз, и хранить нужно его хэш. createToken() возвращает строку вида 1|abcdef... — она нигде больше не появится, в базе лежит только хэш. Это правильное поведение, но означает, что «покажите мне мой токен ещё раз» реализовать нельзя: только выпустить новый и отозвать старый. Заложите это в интерфейс сразу, иначе придёте к нему через поддержку.
И про хранение на клиенте: в мобильном приложении токен кладут в Keychain (iOS) или Keystore (Android), а не в обычные настройки. В вебе токен в localStorage доступен любому скрипту на странице, включая внедрённый через XSS, — именно поэтому для SPA на своём домене cookie-режим с HttpOnly предпочтительнее.
Защита роутов
// routes/api.php
// Базовая защита
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn(Request $request) => $request->user());
});
// С проверкой abilities
Route::middleware(['auth:sanctum', 'abilities:admin'])->group(function () {
Route::get('/admin/users', [AdminController::class, 'users']);
});
// С проверкой хотя бы одного ability
Route::middleware(['auth:sanctum', 'ability:read,write'])->group(function () {
// ...
});Тестирование
// tests/Feature/AuthTest.php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
public function test_user_can_login(): void
{
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJsonStructure(['user' => ['id', 'name', 'email']]);
}
public function test_authenticated_user_can_access_profile(): void
{
// Авторизация через Sanctum::actingAs
Sanctum::actingAs(
User::factory()->create(),
['read:profile']
);
$response = $this->getJson('/api/user');
$response->assertOk();
}
public function test_unauthenticated_user_cannot_access_profile(): void
{
$response = $this->getJson('/api/user');
$response->assertUnauthorized();
}CSRF для SPA: Перед login/register всегда вызывайте GET /sanctum/csrf-cookie. Без CSRF-токена POST-запросы будут отклонены.
Итоги
| Сценарий | Подход |
|---|---|
| SPA на том же домене | Cookie-based (stateful) |
| SPA на другом домене | Cookie + правильный CORS |
| Mobile app | API tokens |
| Third-party API | Tokens с abilities |
Чек-лист по режимам.
Cookie-режим (SPA на своём домене):
SANCTUM_STATEFUL_DOMAINSсодержит домен фронтенда с портом, если он нестандартный (localhost:3000).SESSION_DOMAIN=.site.ruдля работы между поддоменами.supports_credentials: trueв конфиге CORS иcredentials: 'include'на фронте — оба, иначе кука не поедет.GET /sanctum/csrf-cookieперед первым изменяющим запросом.- В продакшене —
SESSION_SECURE_COOKIE=true.
Режим токенов (мобильные, сторонние интеграции):
- Токены выдаются с ограниченными
abilities, а не с полным доступом «на всякий случай». - У токенов есть срок жизни (
expirationв конфиге) и способ отзыва. - Есть экран или метод, где пользователь видит свои активные токены и может их отозвать, — это и требование безопасности, и то, что спросят при первом же инциденте.
Диагностика «не авторизуется» в cookie-режиме почти всегда сводится к одному из трёх: кука не установилась (смотрите вкладку Network, заголовок Set-Cookie в ответе), кука не отправляется обратно (нет credentials: 'include' или домен не совпадает), либо запрос не считается stateful (домен не перечислен в SANCTUM_STATEFUL_DOMAINS). Проверять нужно именно в этом порядке — и по вкладке сети браузера, а не по логам приложения: в логах вы увидите только результат.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.