Skip to Content (Press Enter)

Demystifying the XSRF-TOKEN in Web Security

XSRF-TOKEN is Laravel's cross-site request forgery cookie. What it holds, why scripts can read it, and what causes 419 errors.
Middle aged manager using cell phone mobile app.

The XSRF token is why there's a cookie called XSRF-TOKEN in your browser right now. Open your browser's developer tools on this page, go to the Application tab in Chrome (Storage in Firefox), expand Cookies, and you'll see it next to one called d3_creative_session. This site runs on Statamic, a content management system (CMS) built on the Laravel framework, and Laravel sets both cookies.

This post covers what the token is, where Laravel creates it, how it differs from the _token form field and the X-CSRF-TOKEN header, and what's usually behind a 419 "Page Expired" error. The code comes from Laravel 13 and Statamic 6, the versions this site runs.

What is an XSRF token?

XSRF and CSRF are two abbreviations for the same attack: cross-site request forgery. An XSRF token is a secret value a site gives your browser so it can prove a form submission came from its own pages, not from someone else's.

In Laravel, the token lives in your session on the server. The XSRF-TOKEN cookie is an encrypted copy of it that JavaScript on the page can read and send back.

Understanding cross-site request forgery (CSRF)

The Laravel documentation on CSRF protection explains the attack with a route that changes a logged-in user's email address. Another site includes a hidden copy of that form, fills in the attacker's own email address, and submits it with JavaScript as soon as the page loads.

Your browser sends your session cookie with that request, because it attaches a site's cookies to every request going to that site. Without a check, the application can't tell the request started on someone else's page. The attacker now controls the email address on your account, and a password reset does the rest.

The defence is a secret the other site can't get hold of. Laravel generates a random 40-character token for each visitor's session and keeps it on the server. Any request that changes something (a POST, PUT, PATCH or DELETE request) has to send that token back, or Laravel rejects it.

The role of the XSRF-TOKEN cookie in Laravel

The middleware that does the checking is called PreventRequestForgery in Laravel 13. Laravel 11 and 12 called it ValidateCsrfToken, and older apps have VerifyCsrfToken. Both old names still exist and point to the new class. It runs on every route in the web middleware group, which on a Statamic site means every front-end page.

Whenever a request passes, the middleware adds a cookie holding the session's token to the response. These are the headers this site sends, with the long values shortened:

set-cookie: XSRF-TOKEN=eyJpdiI6IndpNENTZDNGanpia1FY...%3D; expires=Wed, 23 Sep 2026 17:50:22 GMT; Max-Age=7200; path=/; secure; samesite=lax
set-cookie: d3_creative_session=eyJpdiI6Ikk5cDA4TjhzamU3...%3D; expires=Wed, 23 Sep 2026 17:50:22 GMT; Max-Age=7200; path=/; secure; httponly; samesite=lax

Three things are worth noticing:

  • The value is encrypted, like every Laravel cookie, which is why it starts with eyJpdiI6 ({"iv": in base64). It changes on every response, but the token inside stays the same for the whole session.

  • The session cookie has the httponly flag and the XSRF-TOKEN cookie doesn't. That's deliberate. HttpOnly stops JavaScript reading a cookie, and this cookie exists so JavaScript can read it.

  • It expires with the session. 7,200 seconds is the default session lifetime of 120 minutes.

The Laravel docs call the cookie a developer convenience. Libraries such as axios and Angular look for a cookie named XSRF-TOKEN and copy its value into an X-XSRF-TOKEN request header on requests to the same site. This is the part of the Laravel 13 middleware that reads the token:

protected function getTokenFromRequest($request)
{
    $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

    if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
        try {
            $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
        } catch (DecryptException) {
            $token = '';
        }
    }

    return $token;
}

Notice that Laravel never reads the cookie itself, only the header. Browsers only let a page read cookies for its own site, so a page on another site has no way to copy the value into a header.

An attacker's page can make your browser send the cookie. It can't read the value, so it can't copy it into the header.

XSRF-TOKEN vs csrf_token, X-CSRF-TOKEN and X-XSRF-TOKEN

There's one token per session and three ways to send it back. The method above checks them in this order:

  1. The _token form field. The plain token in a hidden input, output by @csrf in Blade or {{ csrf_field }} in Antlers. Ordinary form posts use this.

  2. The X-CSRF-TOKEN header. The same plain token, sent as a header. The usual pattern is a <meta name="csrf-token"> tag that your JavaScript reads.

  3. The X-XSRF-TOKEN header. The encrypted value from the cookie, which Laravel decrypts before comparing. That's why the plain token won't work in this header, and the cookie value won't work in X-CSRF-TOKEN.

So CSRF and XSRF aren't two different protections. They're two naming conventions for the same check.

Laravel 13 checks where the request came from first

Laravel 13 added a step before the token check. Modern browsers send a Sec-Fetch-Site header saying whether a request came from the same origin, the same site or somewhere else. If it says same-origin, the middleware lets the request through without looking for a token. Browsers only send the header on secure connections (HTTPS), so on plain HTTP, or in an older browser, Laravel falls back to the token.

You can see both paths with curl against a local copy of this site, which runs over plain HTTP. A POST with no token gets a 419. Add the header and the request gets past the check to the 404 you'd expect for a page that doesn't exist:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://d3creative.test/nonexistent-xyz
419
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -H 'Sec-Fetch-Site: same-origin' http://d3creative.test/nonexistent-xyz
404

Faking the header with curl doesn't weaken anything. The attack depends on a victim's browser, and browsers don't let a page set Sec-Fetch-Site itself.

Why you see XSRF-TOKEN in your browser

Not everyone who finds this cookie is a developer, so here's the short version.

It isn't a tracking cookie. It holds an encrypted copy of a random token tied to your visit to that one site, and it expires when the session does. If you see it next to a cookie ending in _session, the site is most likely built with Laravel, which names its session cookie after the app (laravel_session by default). Angular's HTTP client reads a cookie with the same name, so some sites built on other frameworks set it too.

Security scanners sometimes flag it for missing the HttpOnly flag. As above, that's on purpose. If nothing on your site reads the cookie you can switch it off by extending the middleware and setting $addHttpCookie to false, but it won't make the site safer. A script that could read the cookie could read the token from any form on the page.

What causes a 419 "Page Expired" error

When the check fails, Laravel throws a TokenMismatchException, and its exception handler turns that into a 419 response with the message "Page Expired". On Laravel 12 and earlier, or whenever the Laravel 13 origin check doesn't apply, the cause is nearly always one of these:

  • The session ran out. Someone opens a form, goes to a meeting and submits it after the 120 minutes are up. Their session has gone, so the token in the form doesn't match anything.

  • The page was cached with someone else's token in it. A content delivery network (CDN) set to cache whole pages, or any page cache that doesn't know about CSRF tokens, serves every visitor the token from whoever loaded the page first.

  • The session cookie didn't stick. SESSION_DOMAIN set to the wrong domain, SESSION_SECURE_COOKIE=true on a site served over plain HTTP, or a form embedded in an iframe on another site, where browsers won't send a SameSite=Lax cookie. With no session cookie, every submission starts a new session with a new token.

  • The encryption key or session storage changed. A new APP_KEY means Laravel can't decrypt existing session cookies. With the default file driver, a deploy that empties storage/framework/sessions throws away everyone's session. Anyone with a form open gets a 419 when they submit it.

That last one tends to appear straight after an update or a server move. Checking that forms still submit afterwards, and fixing them when they don't, is part of my website maintenance plans.

How Statamic forms send the token

Statamic's {{ form:create }} tag adds the hidden field for you. On this site's contact page it renders as:

<input type="hidden" name="_token" value="aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5bC7d" autocomplete="off">

A plain form post sends that field and needs nothing else. The contact form here doesn't do a plain post, though. It uses the tag's js="alpine" option with Laravel Precognition, a package that validates fields as you type, and submits in the background. Precognition sends the fields listed in the form's Alpine data, and _token isn't one of them. Instead, its request client reads the cookie:

let xsrfCookieName = options.xsrfCookieName ?? 'XSRF-TOKEN';
let xsrfHeaderName = options.xsrfHeaderName ?? 'X-XSRF-TOKEN';
function getXsrfToken() {
    if (typeof document === 'undefined') {
        return null;
    }
    const match = document.cookie.match(new RegExp('(^|;\\s*)' + xsrfCookieName + '=([^;]*)'));
    return match ? decodeURIComponent(match[2]) : null;
}

// ...later, when building the request
const xsrfToken = getXsrfToken();
if (xsrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
    headers[xsrfHeaderName] = xsrfToken;
}

So the contact form depends on the XSRF-TOKEN cookie, or on the origin check, not on the hidden field.

Static caching and CSRF tokens in Statamic

Statamic's static cache saves each rendered page and serves that copy to the next visitor. A cached page with a real token in it would be the page cache problem above, so Statamic swaps the token out. Before a page is cached, its CsrfTokenReplacer replaces the token with a fixed placeholder string. What happens next depends on the caching strategy:

  • With the half measure (the application driver), cached pages still go through Laravel. The placeholder is swapped for the current visitor's token on the way out, and the middleware sets their XSRF-TOKEN cookie as normal.

  • With the full measure (the file driver), NGINX serves the page as a static file and Laravel never runs. Statamic adds a small script to the page that asks /!/csrf for a fresh token and writes it into every input, meta tag and data-csrf attribute holding the placeholder.

This is the start of that script as it appears in Statamic 6's source, where $csrfPlaceholder is the placeholder:

fetch('/!/csrf', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
})
.then((response) => response.json())
.then((data) => {
    for (const input of document.querySelectorAll('input[value="$csrfPlaceholder"]')) {
        input.value = data.csrf;
    }

    for (const meta of document.querySelectorAll('meta[content="$csrfPlaceholder"]')) {
        meta.content = data.csrf;
    }
    // ...
    document.dispatchEvent(new CustomEvent('statamic:csrf.replaced', { detail: data }));
});

Anything wrapped in {{ nocache }} tags is handled separately. Those regions are rendered fresh by a request to /!/nocache after the page arrives, so a form inside one gets a real token.

The full measure gap for JavaScript forms

There is a catch with the full measure and forms like the contact form above. Statamic registers /!/csrf without the CSRF middleware, and that middleware is what sets the XSRF-TOKEN cookie. The response sets a session cookie and nothing else. A first-time visitor on a fully cached page ends up with a valid token in the hidden field, but no cookie for Precognition or axios to read.

On Laravel 13 over HTTPS, the origin check covers this. On Laravel 12 and earlier, or in a browser that doesn't send Sec-Fetch-Site, that first submission would get a 419. The fix is to send the hidden field's value yourself. Precognition passes any headers you give submit() straight through, so in the contact form's submit handler it would look like this:

this.form.submit({
    headers: {
        'X-CSRF-TOKEN': $refs.form.querySelector('input[name="_token"]').value,
    },
})

Read the field at submit time, not when the page loads, so you get the token after Statamic's script has replaced the placeholder. If other scripts need the token, listen for the statamic:csrf.replaced event, which carries the new token in event.detail.csrf.

If a Statamic form returns a 419, check three things in order: whether the page is cached somewhere that doesn't swap the token, whether the session cookie reaches the browser, and whether the key or session storage changed in the last deploy. If you'd rather someone else tracked it down, that's what my Statamic support covers.

Updated: 19th March, 2024 by Stephen Meehan in Web Development, Statamic
.

Get a measurably better website

Your online presence matters, increase engagement, lower bounce rates, and improve conversions.
Design & Build