# 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.

**Author:** Matt King | **Published:** June 18, 2026 | **Category:** Security

---

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:

```html
<!-- 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:

```blade
{{-- 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:

```php
// 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:

```php
// 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](/blog/owasp-top-10-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:

```js
// 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](/blog/laravel-api-security-rate-limiting-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.

```php
// 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](/blog/cors-misconfigurations-laravel) and [reflected and stored XSS](/blog/laravel-xss-protection-guide), 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](/free-scan) to find the exempted routes and missing tokens across your codebase in one pass.

---

## 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.

