CSRF Protection in Laravel: How It Works and the Mistakes That Disable It
Laravel ships CSRF protection on by default, then developers quietly switch it off one route at a time. Here is how the token actually works, the four changes that disable it, and when you genuinely do not need it.
Your Laravel app has login. Sessions are signed. Passwords are hashed with bcrypt. You might think the form that updates a user's email is safe because only logged-in users can reach it. That is the gap. CSRF does not break authentication. It rides on top of it.
Cross-site request forgery tricks a logged-in user's browser into making a request the user never intended. The browser already holds a valid session cookie, so as far as your server is concerned, the request is authentic. Laravel guards against this out of the box, but the protection is quietly removable, and a surprising number of apps remove it without realising what they gave up.
What CSRF actually is
The browser is helpful to a fault. When a page on evil-site.example submits a form to your-app.example, the browser attaches any cookies it holds for your-app.example automatically. It does not care which page initiated the request. Your session cookie goes along for the ride.
Picture a victim logged into your app in one tab. In another tab they open a page that looks like a harmless article. Buried in that page is this:
<!-- hosted on evil-site.example -->
<form action="https://your-app.example/profile" method="POST" id="x">
<input type="hidden" name="email" value="attacker@evil-site.example">
</form>
<script>document.getElementById('x').submit();</script>
The page loads, the script fires, the form POSTs to /profile. The victim's browser attaches their session cookie. Your controller sees an authenticated request from a real, logged-in user and updates the email to one the attacker controls. From there it is a password reset away from a full account takeover. No XSS, no stolen credentials, no phishing page. Just a hidden form and a browser doing what browsers do.
The defining trait of CSRF is that the attacker never sees the response. They cannot read your session cookie or any data that comes back. They only need the side effect of the request to happen. That is why CSRF targets state-changing endpoints: profile updates, password changes, money transfers, role grants, deletions.
How Laravel protects you
Laravel's answer is the synchroniser token pattern. The server generates a random token, stores it in the session, and requires that same token to come back with every state-changing request. The attacker's page on evil-site.example cannot read the victim's session and has no way to know the token, so the forged request arrives without it and gets rejected.
The enforcement lives in middleware. In Laravel 11 and 12 the web middleware group includes the CSRF check by default, so every route loaded from routes/web.php is covered without you doing anything. The class is Illuminate\Foundation\Http\Middleware\VerifyCsrfToken (registered under the ValidateCsrfToken alias in the newer bootstrap). It compares the token on the request against the one in the session and throws a TokenMismatchException, which surfaces as the familiar 419 status, when they disagree.
The check only runs for POST, PUT, PATCH and DELETE. Read-only verbs like GET and HEAD are skipped, which is fine because those should never change state in the first place. If a GET route in your app mutates data, that is a separate bug worth fixing.
You supply the token in one of two ways.
For server-rendered forms, the @csrf Blade directive renders a hidden input:
{{-- resources/views/profile/edit.blade.php --}}
<form method="POST" action="/profile">
@csrf
<input type="email" name="email" value="{{ $user->email }}">
<button type="submit">Save</button>
</form>
That expands to <input type="hidden" name="_token" value="...">. The middleware reads _token from the request body and matches it. Forget the directive and your own form starts returning 419, which is usually the first time developers meet this system.
For JavaScript clients, Laravel sets an XSRF-TOKEN cookie on every response. It holds the same token, encrypted but readable by same-origin JavaScript. Libraries like axios look for that cookie automatically and copy its value into an X-XSRF-TOKEN header on outgoing requests. The middleware checks that header as a fallback when no _token field is present. If you use the default axios setup that ships with Laravel's frontend scaffolding, this is already configured and you rarely think about it.
The mistakes that disable it
The protection is solid. The problem is how easy it is to switch off, usually while chasing a 419 that felt like an obstacle rather than a warning.
Adding routes to the exception list. The middleware has an $except array for URIs that skip verification. It exists for legitimate cases, such as inbound webhooks from Stripe that cannot carry your token. But it gets abused as a quick fix:
// VULNERABLE — app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'webhooks/stripe',
'profile/*', // added to silence a 419 during development
'admin/*', // "the SPA handles auth anyway"
];
Every URI in that array is a state-changing endpoint with no CSRF protection. profile/* and admin/* are exactly the routes an attacker wants. The fix is to remove anything that is not a true third-party callback and supply the token properly instead:
// FIXED — only genuine external callbacks belong here
protected $except = [
'webhooks/stripe',
'webhooks/github',
];
Removing the middleware from the web group. Some teams strip CSRF out of bootstrap/app.php wholesale because it kept getting in the way. That disables the check for every web route at once. If you are building a session-authenticated app, the middleware belongs in the web group. Leave it there.
Building session-authenticated APIs with no CSRF. A common pattern is an /api route group that uses the auth session guard but lacks CSRF verification, on the assumption that APIs do not need it. That assumption only holds for token-authenticated APIs. The moment a route trusts the session cookie, it is exposed to the same forged-request attack as any web form. Cookie authentication and CSRF protection are a package deal.
Misunderstanding SameSite cookies. More on this below, but the short version is that developers see SameSite=lax in the session config and conclude tokens are now redundant. They are not. Removing token verification because of a cookie attribute is a downgrade dressed up as a cleanup.
This class of mistake overlaps heavily with the broader risks in the OWASP Top 10 for Laravel, where broken access control and CSRF-adjacent flaws sit near the top of the list.
SPAs and Sanctum
Single-page apps using Sanctum's stateful authentication run on session cookies, not Bearer tokens. That means they need CSRF protection exactly like a server-rendered app, and Sanctum builds the flow in.
The sequence matters. Before your SPA makes its first authenticating request, it calls a dedicated endpoint to obtain the XSRF-TOKEN cookie:
// resources/js/auth.js
import axios from 'axios';
// 1. Prime the CSRF cookie
await axios.get('/sanctum/csrf-cookie');
// 2. Now log in — axios reads XSRF-TOKEN and sends X-XSRF-TOKEN automatically
await axios.post('/login', {
email: 'user@example.com',
password: 'secret',
});
The /sanctum/csrf-cookie call returns a 204 and sets the cookie. Every subsequent axios request reads it and attaches the X-XSRF-TOKEN header, so the session-backed login and all later state changes pass verification. Skip the priming call and your first POST lands without a token and bounces with a 419.
Two configuration details break this if you get them wrong. Your SPA origin must appear in Sanctum's stateful domains list, and your SESSION_DOMAIN has to be set so the SPA and API share the cookie. When those line up, the cookie is readable by your frontend and the header round-trips correctly. The rate-limiting and token side of Sanctum is worth understanding too if you mix stateful and token auth in the same app, which we cover in API security with rate limiting and Sanctum.
When you genuinely do not need CSRF tokens
There is one honest exception, and it is worth being precise about because half-understanding it is how the $except array fills up.
A stateless API authenticated with Bearer tokens does not need CSRF protection. The reasoning is mechanical, not a matter of taste. CSRF works because the browser attaches the session cookie automatically on cross-site requests. A Bearer token lives in an Authorization header that the client sets deliberately on each request:
POST /api/orders HTTP/1.1
Host: your-app.example
Authorization: Bearer 1|aBcD3fGh...
Content-Type: application/json
A malicious page on another origin cannot set that header on a cross-origin request without your CORS policy explicitly allowing it, and it has no way to read the victim's token to begin with. There is no ambient credential to abuse, so there is nothing for a CSRF token to defend. Laravel's api middleware group reflects this: it does not include CSRF verification, because it is designed for token authentication.
Here is the line you must not cross. If a route is exempt from CSRF on the grounds that it is token-authenticated, it must reject session cookie authentication. The danger is a route that accepts either a Bearer token or a session cookie, with no CSRF check, because it was filed under "API". An attacker simply uses the cookie path and the forged request sails through.
// VULNERABLE — accepts session cookies, no CSRF check
Route::middleware('auth')->group(function () {
Route::post('/api/transfer', TransferController::class);
});
// FIXED — token guard only, no ambient cookie credential to forge
Route::middleware('auth:sanctum')->group(function () {
Route::post('/api/transfer', TransferController::class);
});
Pick one model per route and commit to it. Token-authenticated and stateless, or cookie-authenticated and CSRF-protected. The mixed middle ground is where the breaches live.
How SameSite interacts with all of this
Laravel sets 'same_site' => 'lax' in config/session.php by default. A SameSite=lax cookie is not sent on most cross-site requests, including the cross-origin POST from a hidden form. So in many browsers, the attack in the opening example is already blunted before the token check even runs, because the session cookie never arrives.
That is real protection, but it is not the whole story, and here is why you keep the tokens.
| Defence | Stops cross-site POST | Stops top-level GET navigation | Depends on browser support |
|---|---|---|---|
SameSite=lax cookie |
Yes, in compliant browsers | No | Yes |
| CSRF token verification | Yes | Yes (state-changing verbs only) | No |
| Both together | Yes | Yes | Partially |
SameSite=lax permits the cookie on top-level navigations triggered by the user, which historically left openings, and its enforcement has varied across browser versions over the years. It is a browser-side mitigation you do not fully control. The CSRF token is enforced server-side on every state-changing request regardless of which browser the victim runs. Treat lax as a useful extra layer that narrows the window, and let the token do the actual gatekeeping.
Setting 'same_site' => 'strict' tightens things further but breaks legitimate cross-site flows like following a link from an email straight into an authenticated page, which is why lax is the sensible default for most apps. Whatever you choose, it is a complement to token verification, never a substitute. The same defence-in-depth thinking applies to related browser-trust issues like CORS misconfigurations and reflected and stored XSS, where one layer failing should not mean the whole thing falls.
The one thing to do this week
Open your VerifyCsrfToken middleware and read the $except array out loud. Every entry that is not a verified third-party webhook is a state-changing route running without protection, and it needs the token supplied properly rather than the exemption kept. Then walk your routes/web.php and any session-authenticated API routes and confirm each POST, PUT, PATCH and DELETE either carries @csrf, sends the X-XSRF-TOKEN header, or is genuinely stateless and cookie-free.
Run a free StackShield scan to find the exempted routes and missing tokens across your codebase in one pass.
Is your Laravel app exposed right now?
34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.
Frequently Asked Questions
Does Laravel have CSRF protection enabled by default?
Yes. A fresh Laravel 11 or 12 application enables CSRF verification for every route in the web middleware group. The middleware checks that a token submitted with a state-changing request matches the one stored in the session. You do not have to wire anything up to get this, but you do have to include the token in your forms and AJAX requests, otherwise legitimate POST, PUT, PATCH and DELETE requests will fail with a 419 status.
What is the difference between the @csrf token and the XSRF-TOKEN cookie?
They serve the same purpose through different channels. The @csrf Blade directive renders a hidden form field carrying the token, which is how server-rendered forms pass verification. The XSRF-TOKEN cookie holds the same token in an encrypted, readable cookie so JavaScript clients like axios can copy it into the X-XSRF-TOKEN header automatically. Both ultimately resolve to the value Laravel compares against the session token.
Do REST APIs need CSRF protection in Laravel?
Stateless APIs authenticated purely with Bearer tokens do not need CSRF tokens, because the browser never attaches the credential automatically the way it does with cookies. The attack relies on the victim browser sending an ambient session cookie, and there is no such cookie in a token flow. The catch is that those routes must not also accept session cookie authentication, or you reopen the hole CSRF protection was closing.
Why am I getting a 419 Page Expired error in Laravel?
A 419 means CSRF verification failed. The usual causes are a missing token in the request, an expired session, a stale token cached in the browser, or a form rendered before the session was established. Check that your form includes @csrf, that AJAX requests send the X-XSRF-TOKEN header, and that your session cookie is actually being sent. It is a protection working as designed, not a bug to suppress by excluding the route.
Is SameSite=lax enough to stop CSRF on its own?
No, and you should not treat it as a replacement for tokens. SameSite=lax stops cross-site cookies on most cross-origin requests, which blocks a large class of forged POSTs, but it still permits top-level GET navigations and has historically had gaps and browser inconsistencies. Treat it as defence in depth that narrows the attack surface while Laravel token verification does the actual enforcement. Removing the token because SameSite exists is exactly the kind of mistake that gets flagged in a scan.
Related Articles
Laravel Session Security: Cookies, Hijacking & config/session.php
A deep dive into Laravel session security. Learn how cookie flags, session drivers, and config/session.php settings protect against hijacking, fixation, and sidejacking attacks.
SecurityAutomated Security Testing in Laravel CI/CD Pipelines
How to add security gates to your Laravel CI/CD pipeline with GitHub Actions. Covers dependency scanning, static analysis, secret detection, and automated security monitoring.
SecurityLaravel Content Security Policy: Configure CSP Without Breaking Your App
Only 22% of Laravel apps have a Content Security Policy. Learn how to implement CSP with spatie/laravel-csp, handle Livewire and Vite nonces, and avoid the mistakes that break production.
Compare StackShield
Security Checklists
Laravel Production Deployment Security Checklist
A comprehensive security checklist for deploying Laravel applications to production. Covers environment config, server hardening, access control, and monitoring.
20 itemsLaravel API Security Checklist
Secure your Laravel API endpoints against common vulnerabilities. Covers authentication, input validation, rate limiting, and response security.
Stay Updated on Laravel Security
Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.