Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 046 / 100

CSRF protection in Laravel: patterns, pitfalls and fixes

Prevent forged requests from other sites.

Expona Admin8 min read

Understanding CSRF attacks and Laravel's default defense

Cross-Site Request Forgery (CSRF) is one of the oldest and most damaging web vulnerabilities. An attacker tricks your logged-in user into visiting a malicious site, which silently makes requests to your application on their behalf—transferring funds, changing passwords, or deleting data. CSRF protection in Laravel: patterns, pitfalls and fixes is essential knowledge because the framework handles it automatically for web routes, yet many teams still ship insecure applications by misunderstanding how tokens work or disabling protections unnecessarily.

The good news is that Laravel's middleware-based approach is solid. The bad news is that developers often bypass it without realizing the risk, or they implement CSRF protection inconsistently across APIs and SPAs. This guide walks through the patterns that work, the pitfalls we see in production, and the fixes that keep your applications safe.

CSRF protection is enabled by default for web routes

When you create a fresh Laravel application, the VerifyCsrfToken middleware is already attached to the web middleware group. This means every POST, PUT, PATCH, and DELETE request to your web routes will be checked for a valid CSRF token—no extra setup required.

The middleware sits in app/Http/Middleware/VerifyCsrfToken.php and does one thing: it compares the token sent by the client with the token stored in the session. If they don't match, the request is rejected with a 419 (Token Mismatch) response.

php
// routes/web.php
Route::post('/transfer-funds', [TransferController::class, 'transfer'])->middleware('web');

// The 'web' middleware group already includes VerifyCsrfToken
// No additional work needed—CSRF protection is automatic.

By default, Laravel generates a token when the session starts and stores it in the session. The token also gets written to a cookie named XSRF-TOKEN for JavaScript frameworks to read. This dual storage gives you flexibility: form-based requests send the token in a hidden field, while modern JavaScript apps read it from the cookie.

The key insight here is that you don't have to opt-in. If you're using the web middleware group, CSRF protection is already there. Problems arise when teams either forget to include the token in forms or try to disable the middleware to "simplify" their API.

Using @csrf in forms and when to exclude routes

Every HTML form that performs a state-changing operation must include a CSRF token. Laravel provides the @csrf Blade directive to make this trivial:

php
<form action="/users" method="POST">
    @csrf
    <input type="text" name="email" required>
    <button type="submit">Create User</button>
</form>

The @csrf directive outputs a hidden input field with a valid token. Without it, the request fails with a 419 error. This is intentional—it's a safety mechanism that protects users who forget to add the token.

There are legitimate cases where you need to exclude certain routes from CSRF verification. The middleware allows this via an $except array:

php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'webhook/stripe/*',
    'webhook/github/*',
];

// These routes skip CSRF verification because external services
// cannot provide a valid Laravel CSRF token.

Webhooks are the common exception. A payment provider like Stripe sends webhooks to your application, but Stripe cannot know your CSRF token, so you exclude those routes. However, you must still verify the request authenticity using the webhook provider's signature method—never leave a webhook route completely unprotected.

A dangerous mistake is excluding too many routes or forgetting why a route was excluded. We've seen teams add routes to the $except array during debugging and never remove them. Six months later, they have an unprotected endpoint handling sensitive operations.

APIs with token-based authentication do not need CSRF

CSRF works because an attacker can forge requests using a user's browser cookies. The browser automatically includes cookies with every request to the same domain, so the attacker doesn't need to know the token value—they just rely on the user's session.

APIs that use token-based authentication (Bearer tokens, API keys) are fundamentally different. The token lives in the Authorization header, not in a cookie. A browser cannot automatically include a custom header in cross-origin requests—that would require explicit JavaScript code, and the browser's CORS policy prevents scripts from reading or setting many sensitive headers without server permission.

Because of this, CSRF protection is not needed for stateless API routes that use token authentication. If your API endpoint requires an Authorization: Bearer <token> header, CSRF protection adds no security and only adds latency.

php
// routes/api.php
Route::post('/api/transfer-funds', [TransferController::class, 'transfer'])
    ->middleware('auth:api')
    ->withoutMiddleware('VerifyCsrfToken'); // API token auth, no CSRF needed

// The client must send:
// Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
// An attacker cannot forge this header from a browser.

If you register API routes using the api middleware group, CSRF verification is already disabled by default. The api group does not include VerifyCsrfToken, so your endpoints are safe from CSRF by design.

The critical rule: use CSRF protection for web routes that rely on session cookies. Disable it for API routes that use token authentication. Mixing the two patterns causes confusion and security gaps.

Laravel Sanctum is the official library for building SPAs that consume your own API. It handles authentication without sending tokens in the URL or the request body—instead, it relies on cookies and the XSRF-TOKEN header pattern, which is CSRF-safe.

When you configure Sanctum for SPA mode, it sets up a middleware that issues a session cookie after the user logs in. It also generates and returns an XSRF-TOKEN cookie. Your JavaScript application reads this cookie and includes the token in a request header (typically X-XSRF-TOKEN). Because the token is read from the cookie and sent in a custom header, it's protected against CSRF attacks.

php
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000',
    env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),

// routes/api.php
Route::post('/login', [AuthController::class, 'login']); // Returns session + XSRF-TOKEN cookie
Route::post('/api/transfer-funds', [TransferController::class, 'transfer'])
    ->middleware('auth:sanctum'); // Checks session and XSRF-TOKEN header

On the frontend, fetch the XSRF token and include it in requests:

php
// Client-side JavaScript
const token = document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];

fetch('/api/transfer-funds', {
    method: 'POST',
    headers: {
        'X-XSRF-TOKEN': token,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({ amount: 100 }),
});

The Sanctum SPA pattern is secure because it combines session cookies (familiar and browser-native) with custom headers (CSRF-proof). The token is generated server-side, tied to the session, and verified on every request. This approach works well for single-page applications hosted on the same domain as the API.

If your SPA is on a different domain (e.g., app.example.com consuming api.example.com, the XSRF cookie can still be sent if you configure CORS correctly, but you must ensure that the cookie is set with SameSite=Lax or SameSite=Strict.

Common mistakes and how to avoid them

Disabling CSRF for convenience during development and forgetting to re-enable it. Never add routes to the $except array as a debugging step. If you need to test without CSRF, use a dedicated testing environment or add a test-only condition, then remove it before committing.

Mixing session-based and token-based authentication without protecting both correctly. If you have a hybrid application with both web forms and API endpoints, ensure web routes use CSRF and API routes use token auth—not the other way around, and never both on the same route unless you understand the implications.

Forgetting to include @csrf in dynamically generated forms. If you render forms in JavaScript or load them via AJAX, you must still include the CSRF token. This is easy to overlook in Vue or React components that fetch HTML from the server.

php
// Bad: AJAX form without CSRF token
fetch('/users', {
    method: 'POST',
    body: new FormData(document.querySelector('form')),
}); // Fails with 419

// Good: include the XSRF-TOKEN header
fetch('/users', {
    method: 'POST',
    headers: { 'X-XSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content },
    body: new FormData(document.querySelector('form')),
});

Treating CSRF token expiration incorrectly. Tokens are regenerated on login and can be regenerated on demand, but they don't have a short expiration time—the token lives as long as the session. If a user's session expires, they'll get a 419 error on the next form submission, which is expected behavior. Don't try to "fix" this by extending token lifetime; instead, educate users to re-login.

Excluding webhook routes but not verifying the webhook signature. If you exclude a webhook route from CSRF, you must verify the request using the webhook provider's signature. Stripe, GitHub, and other services sign their webhooks with a secret key that only you know. Verify the signature server-side before processing the webhook.

php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'webhook/stripe/*',
];

// app/Http/Controllers/WebhookController.php
public function stripe(Request $request)
{
    $signature = $request->header('Stripe-Signature');
    try {
        $event = \Stripe\Webhook::constructEvent(
            $request->getContent(),
            $signature,
            config('services.stripe.webhook_secret')
        );
    } catch (\UnhandledMatchException $e) {
        return response('Signature mismatch', 403);
    }
    // Safe to process the webhook
}

Key takeaways

  • CSRF protection is enabled by default for all web routes via the VerifyCsrfToken middleware—you don't need to manually enable it.
  • Always include @csrf in every form that performs a state-changing operation (POST, PUT, PATCH, DELETE).
  • API routes using token-based authentication (Bearer tokens) do not need CSRF protection because tokens are not cookies and cannot be automatically included by a browser.
  • Exclude routes from CSRF verification only for webhooks, and verify those webhooks using the provider's signature method instead.
  • Sanctum SPA mode provides a secure CSRF pattern by combining session cookies with the XSRF-TOKEN header—configure it once and use it consistently.
  • Never disable CSRF protection as a debugging step; use feature flags or test-only environments instead.
  • Understand the difference between session-based and token-based authentication, and apply the right protection for each.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles