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

URL: https://dmeshcheryakov.ru/blog/laravel-sanctum-auth/
Раздел: Laravel
Теги: Laravel, Sanctum, Auth, API, SPA
Опубликовано: 2026-07-01
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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

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

## Чем Sanctum отличается от Passport

| Критерий | Sanctum | Passport |
|----------|---------|----------|
| Сложность | Простой | Сложный |
| 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);
    }
}
```

## Сколько должен жить токен и как его отозвать

```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 app | API tokens |
| Third-party API | Tokens с 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`). Проверять нужно именно в этом порядке — и по вкладке сети браузера, а не по логам приложения: в логах вы увидите только результат.