MidТеория7 min

CSRF Protection

CSRF-токены, X-CSRF-TOKEN, исключение URI, особенности для SPA

Что такое CSRF

Cross-Site Request Forgery (CSRF) -- это атака, при которой злоумышленник заставляет авторизованного пользователя выполнить нежелательное действие на веб-приложении. Laravel автоматически защищает от CSRF-атак с помощью токенов.

Принцип работы

<?php
declare(strict_types=1);

// How CSRF protection works in Laravel:
//
// 1. Laravel generates a unique CSRF token for each user session
// 2. Token is stored in the session
// 3. Every form (POST, PUT, PATCH, DELETE) must include this token
// 4. VerifyCsrfToken middleware compares submitted token with session token
// 5. If tokens don't match → HTTP 419 "Page Expired"
//
// Requests that DON'T need CSRF protection:
// - GET, HEAD, OPTIONS (safe/idempotent methods)
// - API routes (use token-based auth instead)

// The CSRF token is regenerated:
// - On each new session
// - NOT on every request (it persists through the session)

Для экзамена: CSRF-защита применяется только к "небезопасным" HTTP-методам: POST, PUT, PATCH, DELETE. Запросы GET, HEAD, OPTIONS не проверяются, так как они должны быть идемпотентными и не изменять состояние сервера.

Включение CSRF-токена в формы

Blade-директива @csrf

<?php
declare(strict_types=1);

// In Blade templates, use @csrf directive
// This generates a hidden input field with the CSRF token
?>

{{-- Standard form with CSRF --}}
<form method="POST" action="/users">
    @csrf
    {{-- Generates: <input type="hidden" name="_token" value="token_value"> --}}

    <input type="text" name="name" required>
    <input type="email" name="email" required>
    <button type="submit">Create User</button>
</form>

{{-- PUT/PATCH/DELETE forms need @method as well --}}
<form method="POST" action="/users/{{ $user->id }}">
    @csrf
    @method('PUT')
    {{-- Generates: <input type="hidden" name="_method" value="PUT"> --}}

    <input type="text" name="name" value="{{ $user->name }}">
    <button type="submit">Update</button>
</form>

{{-- DELETE form --}}
<form method="POST" action="/users/{{ $user->id }}">
    @csrf
    @method('DELETE')
    <button type="submit">Delete User</button>
</form>

Важно: HTML-формы поддерживают только GET и POST. Для PUT, PATCH, DELETE Laravel использует скрытое поле _method (method spoofing). Директива @method('PUT') генерирует <input type="hidden" name="_method" value="PUT">. Laravel перехватывает это поле и маршрутизирует запрос как PUT.

CSRF-функции и хелперы

<?php
declare(strict_types=1);

// Get CSRF token value
$token = csrf_token();

// Generate hidden input field
$field = csrf_field();
// Returns: <input type="hidden" name="_token" value="token_value">

// In JavaScript
// The token is also available via meta tag (by convention):
?>

<head>
    <meta name="csrf-token" content="{{ csrf_token() }}">
</head>

<?php
// Access in JS:
// document.querySelector('meta[name="csrf-token"]').getAttribute('content')

X-CSRF-TOKEN заголовок

Для AJAX-запросов удобнее передавать CSRF-токен через HTTP-заголовок X-CSRF-TOKEN.

Настройка для JavaScript (Axios)

<?php
declare(strict_types=1);

// resources/js/bootstrap.js
// Laravel's default Axios configuration:
?>

<script>
// Method 1: Axios global configuration
import axios from 'axios';

// Automatically read CSRF token from meta tag
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

axios.defaults.headers.common['X-CSRF-TOKEN'] = token;
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

// Now all Axios requests include the CSRF token
axios.post('/api/users', { name: 'John' });
// Header: X-CSRF-TOKEN: token_value

// Method 2: Fetch API
fetch('/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': token,
        'Accept': 'application/json',
    },
    body: JSON.stringify({ name: 'John' }),
});

// Method 3: jQuery (legacy)
$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});
</script>
<?php
declare(strict_types=1);

// Laravel also sets XSRF-TOKEN cookie
// This is used by frameworks like Angular automatically

// The XSRF-TOKEN cookie:
// - Is set automatically by Laravel's EncryptCookies middleware
// - Contains the CSRF token value (encrypted)
// - JavaScript frameworks can read this cookie
// - And send it as X-XSRF-TOKEN header

// Difference between X-CSRF-TOKEN and X-XSRF-TOKEN:
// X-CSRF-TOKEN  → plain token value (from meta tag or @csrf)
// X-XSRF-TOKEN → encrypted token value (from XSRF-TOKEN cookie)

// Laravel accepts BOTH headers
// It decrypts X-XSRF-TOKEN automatically before comparison

// This is useful for SPA frameworks that automatically
// handle XSRF-TOKEN cookies (Angular, Axios with withCredentials)

Ловушка экзамена: X-CSRF-TOKEN содержит незашифрованный токен (из meta-тега или csrf_token()). X-XSRF-TOKEN содержит зашифрованный токен (из cookie XSRF-TOKEN). Laravel принимает оба варианта. Cookie XSRF-TOKEN устанавливается автоматически middleware EncryptCookies.

CSRF и SPA (Single Page Applications)

Laravel Sanctum + SPA

<?php
declare(strict_types=1);

// For SPA authentication with Laravel Sanctum:

// 1. SPA sends GET request to /sanctum/csrf-cookie
// This endpoint sets the XSRF-TOKEN cookie

// 2. SPA includes the cookie in subsequent requests
// Axios with withCredentials: true handles this automatically

// Configuration for SPA:
// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'supports_credentials' => true, // Important for cookies
];

// config/sanctum.php
return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1'
    )),
];
// SPA JavaScript (e.g., Vue/React with Axios)

// Step 1: Initialize CSRF protection
await axios.get('/sanctum/csrf-cookie');
// This sets XSRF-TOKEN cookie

// Step 2: Login (cookie is sent automatically)
await axios.post('/login', {
    email: '[email protected]',
    password: 'password',
});

// Step 3: Make authenticated requests
// XSRF-TOKEN cookie is included automatically
const response = await axios.get('/api/user');

// Axios configuration:
// axios.defaults.withCredentials = true;
// This ensures cookies are sent with cross-origin requests

Исключение URI из CSRF-проверки

В Laravel 11

<?php
declare(strict_types=1);

// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        // Exclude URIs from CSRF verification
        $middleware->validateCsrfTokens(except: [
            'stripe/*',           // All Stripe webhook URLs
            'paypal/webhook',     // PayPal webhook
            'api/external/*',     // External API callbacks
            'health-check',       // Health check endpoint
        ]);
    })
    ->create();

В Laravel 10 (для справки)

<?php
declare(strict_types=1);

// In Laravel 10, exclusions were in the middleware class
namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * URIs that should be excluded from CSRF verification.
     */
    protected $except = [
        'stripe/*',
        'paypal/webhook',
    ];
}

Для Senior: Исключайте из CSRF-проверки ТОЛЬКО вебхуки от внешних сервисов (Stripe, PayPal, GitHub). Для таких вебхуков используйте альтернативную аутентификацию: проверку подписи (signature verification), IP-whitelisting или shared secrets.

Верификация вебхуков без CSRF

<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

final class StripeWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // Verify webhook signature instead of CSRF token
        $payload = $request->getContent();
        $sigHeader = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook_secret');

        try {
            $event = \Stripe\Webhook::constructEvent(
                $payload,
                $sigHeader,
                $secret
            );
        } catch (\Exception $e) {
            return response()->json(['error' => 'Invalid signature'], 403);
        }

        // Process the webhook event
        match ($event->type) {
            'payment_intent.succeeded' => $this->handlePaymentSuccess($event),
            'customer.subscription.deleted' => $this->handleSubscriptionCancelled($event),
            default => null,
        };

        return response()->json(['status' => 'processed']);
    }
}

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

<?php
declare(strict_types=1);

namespace Tests\Feature;

use Tests\TestCase;

final class UserTest extends TestCase
{
    public function test_create_user(): void
    {
        // In tests, CSRF verification is automatically DISABLED
        // by WithoutMiddleware trait or by default configuration

        $response = $this->post('/users', [
            'name' => 'John',
            'email' => '[email protected]',
        ]);

        $response->assertStatus(201);
    }

    public function test_csrf_is_required_in_production(): void
    {
        // If you want to test WITH CSRF protection enabled:
        $response = $this->post('/users', [
            'name' => 'John',
            // No _token — should fail
        ]);

        // Note: Laravel test helpers automatically handle CSRF
        // The withoutMiddleware method can be used to test specific scenarios
    }

    public function test_webhook_without_csrf(): void
    {
        // Webhook endpoint is excluded from CSRF
        $response = $this->postJson('/stripe/webhook', [
            'type' => 'payment_intent.succeeded',
            'data' => ['object' => ['id' => 'pi_123']],
        ], [
            'Stripe-Signature' => $this->generateValidSignature(),
        ]);

        $response->assertStatus(200);
    }
}

Ошибка 419 "Page Expired"

<?php
declare(strict_types=1);

// HTTP 419 "Page Expired" occurs when:
// 1. CSRF token is missing from the request
// 2. CSRF token doesn't match the session token
// 3. Session has expired (token in form is old)
// 4. Session storage is not working properly

// Common causes:
// - User left form open too long (session expired)
// - Multiple tabs with different sessions
// - Cache/CDN caching HTML with old CSRF tokens
// - Load balancer not sharing sessions between servers

// Solutions:
// - Use sticky sessions or shared session storage (Redis, database)
// - Implement session refresh via AJAX
// - Show user-friendly error page for 419

// Custom 419 error page:
// Create: resources/views/errors/419.blade.php
?>

{{-- resources/views/errors/419.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="text-center">
    <h1>419 - Страница устарела</h1>
    <p>Ваша сессия истекла. Пожалуйста, обновите страницу и попробуйте снова.</p>
    <a href="{{ url()->previous() }}">Вернуться назад</a>
</div>
@endsection

Продвинутые сценарии

CSRF и кэширование

<?php
declare(strict_types=1);

// PROBLEM: Full-page caching (Varnish, CDN) caches the CSRF token
// All users get the SAME token → validation fails

// SOLUTION 1: Exclude pages with forms from caching
// SOLUTION 2: Load CSRF token via AJAX after page load
// SOLUTION 3: Use JavaScript framework with XSRF-TOKEN cookie

// Solution 2 implementation:
// API endpoint that returns fresh CSRF token
use Illuminate\Support\Facades\Route;

Route::get('/csrf-token', function () {
    return response()->json([
        'token' => csrf_token(),
    ]);
});

// JavaScript:
// async function getToken() {
//     const response = await fetch('/csrf-token');
//     const data = await response.json();
//     return data.token;
// }

CSRF и API-маршруты

<?php
declare(strict_types=1);

// API routes (routes/api.php) do NOT have CSRF protection
// They use the 'api' middleware group (no VerifyCsrfToken)

// For API authentication, use:
// 1. Laravel Sanctum (tokens or SPA cookies)
// 2. Laravel Passport (OAuth2)
// 3. Custom token-based auth

// SPA with Sanctum uses CSRF via cookies (stateful):
// - SPA calls /sanctum/csrf-cookie first
// - Then session-based auth with CSRF cookie

// Mobile/third-party APIs use tokens (stateless):
// - No CSRF needed
// - Authorization: Bearer <token>

Проверь себя

Как исключить URL из CSRF-проверки в Laravel 11?

В чём разница между заголовками `X-CSRF-TOKEN` и `X-XSRF-TOKEN`?

Нужна ли CSRF-защита для API-маршрутов, использующих Bearer token аутентификацию?

Какой HTTP-код возвращает Laravel при несовпадении CSRF-токена?

К каким HTTP-методам применяется CSRF-защита в Laravel?