Главная/Статьи/Laravel Sanctum — аутентификация для SPA и мобильных приложений

Laravel Sanctum — аутентификация для SPA и мобильных приложений

Sanctum — простая аутентификация через токены и cookies. Настраиваем для SPA (Next.js, Vue) и мобильных приложений. CSRF, CORS, refresh tokens.

ДМ
Дмитрий Мещеряков
📅 1 июля 2026 г.📖 7 мин чтения

Sanctum решает две принципиально разные задачи одним пакетом, и именно это чаще всего сбивает с толку: SPA-аутентификация через куки и API-токены для мобильных приложений — не варианты одного механизма, а разные механизмы с разными требованиями к настройке.

Разберём оба и, отдельно, где они ломаются: почти все проблемы с Sanctum — это проблемы с доменами, куками и CORS, а не с самим пакетом.

Sanctum vs Passport

КритерийSanctumPassport
СложностьПростойСложный
OAuth 2.0НетДа
Для SPAИдеаленИзбыточен
Для мобильныхДаДа
Third-party OAuthНетДа
💡 Совет

Sanctum — для своих приложений (SPA, mobile). Passport — когда нужен полноценный OAuth 2.0 сервер для третьих сторон.

Установка

bash
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
php
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Режим 1: SPA Authentication (cookies)

Идеален для SPA на том же домене или поддомене.

Настройка CORS

php
// 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

php
// config/sanctum.php
return [
    'stateful' => explode(',', env(
        'SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000'
    )),
];
bash
# .env
SESSION_DOMAIN=.myapp.com
SANCTUM_STATEFUL_DOMAINS=myapp.com,api.myapp.com
FRONTEND_URL=https://myapp.com

API routes

php
// 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']);
});

Контроллер

php
// 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)

typescript
// 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 не подходят.

Выдача токена

php
// 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']);
}

Использование токена

bash
curl -X GET https://api.example.com/api/user \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -H "Accept: application/json"

Token Abilities (права)

php
// Создание токена с ограниченными правами
$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

php
// config/sanctum.php
return [
    'expiration' => 60 * 24 * 7, // 7 дней в минутах
];
php
// Ручная проверка и обновление
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 предпочтительнее.

Защита роутов

php
// 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 () {
    // ...
});

Тестирование

php
// 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 appAPI tokens
Third-party APITokens с abilities

Чек-лист по режимам.

Cookie-режим (SPA на своём домене):

  1. SANCTUM_STATEFUL_DOMAINS содержит домен фронтенда с портом, если он нестандартный (localhost:3000).
  2. SESSION_DOMAIN=.site.ru для работы между поддоменами.
  3. supports_credentials: true в конфиге CORS и credentials: 'include' на фронте — оба, иначе кука не поедет.
  4. GET /sanctum/csrf-cookie перед первым изменяющим запросом.
  5. В продакшене — SESSION_SECURE_COOKIE=true.

Режим токенов (мобильные, сторонние интеграции):

  1. Токены выдаются с ограниченными abilities, а не с полным доступом «на всякий случай».
  2. У токенов есть срок жизни (expiration в конфиге) и способ отзыва.
  3. Есть экран или метод, где пользователь видит свои активные токены и может их отозвать, — это и требование безопасности, и то, что спросят при первом же инциденте.
💡 Совет

Диагностика «не авторизуется» в cookie-режиме почти всегда сводится к одному из трёх: кука не установилась (смотрите вкладку Network, заголовок Set-Cookie в ответе), кука не отправляется обратно (нет credentials: 'include' или домен не совпадает), либо запрос не считается stateful (домен не перечислен в SANCTUM_STATEFUL_DOMAINS). Проверять нужно именно в этом порядке — и по вкладке сети браузера, а не по логам приложения: в логах вы увидите только результат.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.