skip to content

CSRF Middleware & Tokens

Laravel 13's PreventRequestForgery middleware checks a session token or the Sec-Fetch-Site origin on every web write, answering 419 on a mismatch. Asked about SPAs, webhooks and tests.

on this pageshow

explore

questions

4

In a Laravel 13 Blade form, what does @csrf add, and when does a POST without a valid token fail with a 419 error?

level: juniorimportance: must knowfreq 78%

answer

  1. a hidden field named _token
  2. csrf_token() reads the session token
  3. PreventRequestForgery in the web group
  4. Sec-Fetch-Site: same-origin skips the token
  5. TokenMismatchException becomes 419 Page Expired

basics

~20 s

@csrf renders a hidden _token input holding the session's CSRF token. In Laravel 13, PreventRequestForgery lets same-origin browser requests through by Sec-Fetch-Site; otherwise a missing or stale token throws TokenMismatchException, rendered as 419 Page Expired.

solid answer

~50 s

`@csrf` compiles to `csrf_field()`, a hidden `<input name="_token">` whose value is `csrf_token()`, the random token stored in the user's session. The `PreventRequestForgery` middleware, which is in the `web` group, lets a request through when it is a `GET`, `HEAD` or `OPTIONS` request, when the app is running unit tests, when the URI is excluded, when the browser's `Sec-Fetch-Site` header says `same-origin`, or when the submitted token matches the session's. Otherwise it throws `TokenMismatchException` ("CSRF token mismatch."), which the exception handler turns into HTTP 419, shown as "Page Expired". In Laravel 13 a same-origin form over HTTPS in a modern browser therefore passes even without `@csrf`. The 419 appears for requests without that header: plain HTTP, older browsers, cross-site posts, server-to-server calls. The token itself goes stale when the session expires or is regenerated, so an old tab gets 419 too. Keep `@csrf` in every form.

code

html · 8 lines
html
<form method="POST" action="/profile">
    @csrf
    <input type="text" name="display_name">
    <button type="submit">Save</button>
</form>

<!-- @csrf renders: -->
<input type="hidden" name="_token" value="{{ csrf_token() }}" autocomplete="off">

go deeper

for a junior

Know that @csrf adds a hidden _token field, that the check runs on POST, PUT, PATCH and DELETE in the web group, and that a mismatch shows 419 Page Expired.

for a middle

Explain the middleware's order of checks, where the token comes from, and why an expired or regenerated session produces 419 on an old tab.

for a senior

Diagnose unexpected 419s across cookies, domains, caches and HTTP vs HTTPS, and explain why Sec-Fetch-Site does not make @csrf optional.

for a principal

Set the team's stance on forgery defences: token fallback versus origin-only mode, and how to verify it given tests skip the middleware.

## What @csrf renders Laravel generates a random 40-character **CSRF token** for each session and keeps it in the session under `_token`. The Blade directive `@csrf` compiles to `csrf_field()`, which outputs: ```html <input type="hidden" name="_token" value="...the session token..." autocomplete="off"> ``` `csrf_token()` returns the same value, so you can also place it in a meta tag or pass it to JavaScript. The token is **per session**, not per form: every form on every page carries the same value until the session's token is regenerated, which happens when the session ID is regenerated, typically at login. ## The checks PreventRequestForgery runs `Illuminate\Foundation\Http\Middleware\PreventRequestForgery` sits in the default `web` middleware group, after the session has started. In Laravel 13 it lets a request through if **any** of these holds, in this order: 1. the method is `GET`, `HEAD` or `OPTIONS` (reads are not checked); 2. the application is running unit tests; 3. the URI matches an excluded pattern; 4. the `Sec-Fetch-Site` request header is `same-origin`, or `same-site` when same-site requests are allowed; 5. the token from the `_token` input, the `X-CSRF-TOKEN` header or the decrypted `X-XSRF-TOKEN` header matches the session token, compared with `hash_equals()`. If none holds, it throws `Illuminate\Session\TokenMismatchException` with the message "CSRF token mismatch." ## How a mismatch becomes 419 The framework's exception handler maps `TokenMismatchException` to an `HttpException` with status **419**, a non-standard code Laravel uses for "the page you submitted from has expired". The built-in error page reads "Page Expired". The exception is on the handler's internal do-not-report list, so it does not fill your logs. ## What Laravel 13 changed Before Laravel 13 the middleware went by `VerifyCsrfToken` and `ValidateCsrfToken`, and a missing token always meant 419. Laravel 13 renamed it to `PreventRequestForgery`, keeping the old names as deprecated subclasses, and added the `Sec-Fetch-Site` check. Browsers send that header over HTTPS and mark a form posted from your own pages as `same-origin`, so such a request passes before the token is even read. | Request | Laravel 13 result without a valid token | |---|---| | Same-origin form over HTTPS, modern browser | passes on `Sec-Fetch-Site` | | Same form over plain HTTP or from a browser without the header | 419 | | Form auto-submitted from another site | 419 | | Server-to-server POST (no browser headers) | 419 | ## Why forms still need @csrf - **HTTP and older clients.** Local development over plain HTTP, and browsers that do not send `Sec-Fetch-Site`, rely on the token. - **Defence in depth.** The token fallback is what protects you when the header is absent. - **Origin-only mode is a separate decision.** Only an explicit configuration turns the token check off. ## The usual causes of an unexpected 419 - **The session expired.** After `SESSION_LIFETIME` minutes idle, a new session with a new token starts, so an old tab submits a token the server no longer has. - **The session was regenerated.** Logging in on another tab regenerates the session and its token. - **The session cookie never arrived.** A wrong session domain, a `secure` cookie on plain HTTP, or a page served from a different host than the form's action. - **A cached page.** A full-page cache served one user's HTML, with its token, to others. - **A missing `@csrf`** on a form that is submitted over HTTP or from an older browser. Feature tests will not catch a missing `@csrf`: the middleware skips its checks while unit tests run.

  • Why does a feature test that posts a form without @csrf pass even though the real form would fail over HTTP?
    `PreventRequestForgery` returns early when the app is running unit tests, so no token is checked during `$this->post(...)`. The test proves the controller works, not that the form carries a token. A browser test, or a unit test that exercises the middleware directly, is needed to catch a missing `@csrf`.
  • Why does a user who left a form open overnight get 419 Page Expired when submitting it?
    The session expired after `SESSION_LIFETIME` idle minutes, so the next request starts a new session with a new token. The `_token` in the old page no longer matches. If the browser sends `Sec-Fetch-Site: same-origin` over HTTPS the CSRF check passes, but the user is no longer logged in, so on an `auth`-protected route they are redirected to log in instead.

saying these in an interview costs you the question

  • @csrf generates a new token for each form it renders.
  • 419 means the user lacks permission for the action.
  • In Laravel 13 every POST without _token is rejected with 419.
  • Feature tests fail when a form forgets @csrf.
  • A GET route that changes data is protected by the CSRF middleware.
open as a page

A payment provider's webhook POST to your Laravel 13 app gets 419 responses; why, and how do you exempt that route correctly?

level: middleimportance: must knowfreq 55%

basics

~20 s

Routes in routes/web.php run PreventRequestForgery, and a server-to-server webhook sends neither Sec-Fetch-Site nor a CSRF token, so it gets 419. Exempt the URI with preventRequestForgery(except:) or move the route outside the web group, then verify the provider's signature.

open as a page

For JavaScript requests to a Laravel app, how do the X-CSRF-TOKEN header, the XSRF-TOKEN cookie and the X-XSRF-TOKEN header differ?

level: middleimportance: should knowfreq 48%

basics

~10 s

X-CSRF-TOKEN carries the plain session token, usually read from a csrf_token() meta tag. XSRF-TOKEN is a cookie holding the token encrypted; clients like Axios echo it in X-XSRF-TOKEN, which Laravel decrypts before comparing.

open as a page

In Laravel 13, how does PreventRequestForgery use the Sec-Fetch-Site header, and what changes when you enable originOnly or allowSameSite?

level: seniorimportance: should knowfreq 30%

basics

~10 s

PreventRequestForgery accepts a write when Sec-Fetch-Site is same-origin, otherwise falls back to the CSRF token. originOnly drops the fallback, answering 403 via OriginMismatchException; allowSameSite also accepts same-site values from sibling subdomains.

open as a page