The CSRF token: what it protects, when you still need it, and how Symfony, Laravel, Django and Rails handle it
This article starts from an exercise you can do in your browser: Searching with a request body thanks to the QUERY method →
"The CSRF token is invalid. Please try to resubmit the form." in Symfony, "419 Page Expired" in Laravel, "CSRF verification failed. Request aborted." in Django: every web developer has run into one of these errors. The CSRF token is one of those protections everyone uses, because the framework turns it on by default, and few people can explain. In my audits I regularly find routes excluded from the check "because it was getting in the way", or, the other way round, Bearer token APIs that demand a CSRF token for nothing.
This article covers the whole question: what the token really protects, when it is still needed in the age of SameSite cookies, and how Symfony, Laravel, Django and Rails take care of it. Along the way, it explains a detail from a recent Practice exercise: why a simple search using the new HTTP QUERY method can be asked for a token.
The attack: making the victim's browser act
CSRF stands for Cross-Site Request Forgery. The attacker steals nothing. They get the victim's browser to send a request to a site where the victim is logged in.
Imagine you are logged in to your bank, and you then visit a booby-trapped page that contains this:
<form action="https://bank.example/transfer" method="POST" id="trap"> <input type="hidden" name="iban" value="GB33 ... attacker's account"> <input type="hidden" name="amount" value="2000"> </form> <script>document.getElementById('trap').submit();</script>
The form submits itself to the bank. The browser attaches the bank's session cookie, as it does for any request to that domain. On the server side, nothing distinguishes this request from a genuine transfer: right session, right fields, right method.
That is the key point: the server recognises the user by a cookie that the browser sends on its own. As long as that is the case, any site can trigger actions on the user's behalf. The attacker cannot see the response, the same-origin policy prevents it, but they do not need to: the transfer is done.
What the token changes
The CSRF token is a random value that the server places in its own pages, usually in a hidden field, and requires on every submission. The attacker's site can build a form that targets the bank, but it cannot read a page from the bank to grab the token: the same-origin policy forbids it. Its request arrives without a valid token, and the server rejects it.
So the token does not protect you against an attacker who can run JavaScript on your own domain. An XSS flaw reads the token from the page just like your own code would. CSRF and XSS are two separate problems, and you cannot fix the first without fixing the second.
When do you need a token?
The rule fits in one sentence: any request that changes something, on an application that authenticates with a cookie.
- Requests that change something:
POST,PUT,PATCH,DELETE. Reads (GET,HEAD,OPTIONS) do not need one, provided they really change nothing. A deletion triggered by aGETlink is protected by no framework at all: a simple<img src="https://site.example/dish/12/delete">tag on another page is enough to trigger it. - Cookie authentication: a classic session, a "remember me" cookie, or an API token stored in a cookie. It is the automatic sending of the cookie that makes the attack possible.
- The login form too. An attacker can log the victim in to the attacker's account, then collect whatever the victim enters there (an address, a saved card). This is login CSRF. Symfony covers it with the
enable_csrfoption ofform_login.
Conversely, an API that authenticates with the Authorization: Bearer ... header does not need a CSRF token: another site cannot get the victim's browser to add that header. That is exactly why Laravel's api group does not check the token.
What about SameSite cookies?
A cookie's SameSite attribute tells the browser not to send it on requests coming from another site. With SameSite=Lax, the session cookie is no longer sent with the trap form's POST: the attack above fails, even without a token. Symfony and Laravel set SameSite=Lax on their session cookie by default. Chrome also applies a "Lax by default" rule to cookies that specify nothing, in a looser form: for two minutes after it is set, the cookie still goes along with a top-level POST. Firefox and Safari do not make that choice by default.
It is an excellent second line of defence, but I never leave it on its own:
Laxlets top-levelGETnavigations through. A link clicked on another site carries the cookie. An action triggered byGETtherefore remains exposed.- "Same site" is not "same origin".
blog.example.comandapp.example.comare the same site as far asSameSiteis concerned. A compromised subdomain, or one hosted by a third party, bypasses the protection. - Not every browser applies it to cookies that specify nothing, and a configuration change can disable it without anyone noticing, for example
SameSite=Noneset to make an iframe integration work.
The case of the QUERY method
The HTTP QUERY method, standardised in 2026, is a read like GET, but with its criteria in the request body. Laravel 13.35 can route it, and the Practice exercise that introduces this feature includes a warning about the CSRF token. Laravel's CSRF middleware lets read methods (GET, HEAD, OPTIONS) through and checks all the others. QUERY is a read according to its RFC, but Laravel does not count it as one yet: declared in routes/web.php, a QUERY route requires a token, just like a POST. Since Laravel 13 there is a nuance: the middleware also accepts without a token a request that the browser marks as coming from the same origin (the Sec-Fetch-Site: same-origin header). A QUERY sent by your own pages over HTTPS therefore goes through; the 419 mostly shows up in local development over http, where the browser does not send that header.
Is forgetting a token a real risk here? Not really. An HTML form can only send GET and POST, and a QUERY request sent with fetch() from another site triggers a CORS preflight request, which your server rejects unless it allows that origin. The check adds nothing here, and it gets in the way. Hence the exercise's advice: a QUERY search belongs in routes/api.php.
How frameworks take care of it
The principle is the same everywhere. The differences come down to three points: where the token is stored, how it is sent, and what happens when it is missing.
Symfony
The Form component adds and checks the token on its own, in a hidden _token field. For a hand-written form, you generate the token with csrf_token('token_id') in Twig and check it with isCsrfTokenValid() in the controller, or with the #[IsCsrfTokenValid] attribute:
#[Route('/dish/{id}/delete', methods: ['POST'])] #[IsCsrfTokenValid(new Expression('"delete-dish-" ~ args["dish"].getId()'), tokenKey: '_token')] public function delete(Dish $dish): Response
Historically, the token is stored in the session. Since Symfony 7.2, the symfony/form recipe enables stateless tokens for forms: instead of comparing the token with the session, Symfony checks that the Origin or Referer header matches the site's address, with a fallback cookie set by a small Stimulus controller. No session needs to be started just to display a form, which makes public pages easier to cache.
When the check fails, the form is not processed and carries the error "The CSRF token is invalid. Please try to resubmit the form."
Laravel
The PreventRequestForgery middleware (ValidateCsrfToken before Laravel 13) applies to the whole web group. Since Laravel 13, it first lets through requests marked Sec-Fetch-Site: same-origin (see the QUERY case above), then checks the token. In a Blade form, the @csrf directive adds the hidden _token field. For JavaScript calls, Laravel also accepts the token in the X-CSRF-TOKEN header, or in X-XSRF-TOKEN: Laravel sets an XSRF-TOKEN cookie that libraries such as Axios send back in that header automatically.
The token is stored in the session. When it is missing or does not match, Laravel responds with 419 Page Expired, a status code that does not exist in HTTP and that Laravel chose for this error. Exclusions are declared in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) { $middleware->preventRequestForgery(except: ['webhooks/stripe']); })
Excluding a webhook is legitimate: it is not authenticated by a cookie, and it must have its own check, a signature. Excluding a route because it "was returning 419", on the other hand, almost always hides a forgotten @csrf or a lost session.
Django
The CsrfViewMiddleware middleware is enabled by default. In a template, {% csrf_token %} adds the hidden field. The token is stored in a csrftoken cookie (or in the session if you ask for it), and JavaScript sends it back in the X-CSRFToken header. Django also checks the Origin header, and over HTTPS the Referer: behind a proxy or with several domains, you have to declare the accepted origins in CSRF_TRUSTED_ORIGINS.
When the check fails, Django responds with a 403 and the "CSRF verification failed" page.
Rails
ApplicationController inherits the protection: protect_from_forgery is on by default, and raises an exception when the check fails. The csrf_meta_tags helper puts the token in the <head>, and the form helpers add it on their own. On the JavaScript side, Rails UJS or Turbo send it back in the X-CSRF-Token header.
When the check fails, Rails raises ActionController::InvalidAuthenticityToken, which results in a 422 response.
Summary
| Framework | In the form | JavaScript header | On failure |
|---|---|---|---|
| Symfony | _token field (Form component, csrf_token()) |
none by default (stateless tokens: handled by the Stimulus controller) | error on the form, or an exception when checked by hand |
| Laravel | @csrf |
X-CSRF-TOKEN or X-XSRF-TOKEN |
419 Page Expired |
| Django | {% csrf_token %} |
X-CSRFToken |
403 |
| Rails | added by the form helpers | X-CSRF-Token |
422 |
When the error hits a real user
Whatever the framework, the same causes come up when a legitimate user gets rejected:
- The session has expired. The user left the tab open over lunch. It is the most common cause, and the only normal one: show a clear message and offer to resubmit the form.
- The session cookie does not arrive. A misconfigured cookie domain, a
Securecookie on a site served overhttp, a cookie blocked by the browser. - The page is served from a cache. An HTTP cache hands out the same page, and therefore the same token, to everyone. Symfony's stateless tokens exist partly for this case.
- Several servers do not share their sessions. The page comes from server A, the submission lands on server B, which does not know the token.
- The proxy is not declared. The application thinks it is running over
httpwhile the browser announceshttps: every check that compares the origin fails (stateless Symfony, Django).
None of these causes is fixed by disabling the check.
What I take away from it
The CSRF token answers a precise problem: a browser that attaches an authentication cookie on its own to a request triggered by another site. Wherever that is not the case, such as a Bearer API or a method another site cannot send without CORS, it adds almost nothing. Wherever it is the case, it is still necessary, even with SameSite, which is only a second line of defence.
The exercise on the QUERY method takes a few minutes in the browser.