Что такое 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>
X-XSRF-TOKEN (Cookie-based)
<?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содержит зашифрованный токен (из cookieXSRF-TOKEN). Laravel принимает оба варианта. CookieXSRF-TOKENустанавливается автоматически middlewareEncryptCookies.
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>