Back to all articles
#laravel#php#debugging

Laravel 419 Page Expired — What It Is and How to Fix It

That dreaded 419 Page Expired screen in Laravel means your CSRF token went missing. Here's why it happens and how to fix it — from the simple @csrf fix to handling webhooks.

📅 Published: July 2, 2026✍️ Author: Muhammad Amirul Ihsan

The Dreaded 419

You’re building a form. You hit submit. And instead of a success message, Laravel throws this at you:

419 | Page Expired

No stack trace. No helpful error message. Just a blank stare and a feeling of “what did I do wrong?”

If you’ve been coding Laravel for a while, you’ve seen this screen. I’ve been doing Laravel for years and I still forget the fix sometimes. It’s one of those errors that’s simultaneously simple and infuriating.

Let’s break down what’s happening, why it happens, and every way to fix it.


What Causes Error 419?

Laravel protects every POST, PUT, PATCH, and DELETE request with CSRF protection (Cross-Site Request Forgery). It’s a security mechanism that ensures form submissions come from your application, not some malicious third-party site.

Here’s how it works under the hood:

  1. When you visit a page with a form, Laravel generates a unique CSRF token and stores it in your session.
  2. The @csrf Blade directive injects a hidden <input> field containing that token into your form.
  3. When you submit the form, the VerifyCsrfToken middleware checks if the token in the request matches the token in your session.
  4. If they match → request goes through. If they don’t → 419 Page Expired.

Simple, right? So why does it break? Usually one of these three reasons:


Reason 1: You Forgot @csrf in Your Form

This is the most common cause. You write a form, everything looks fine, but you skipped the one line that matters:

<!-- ❌ Missing @csrf — will throw 419 -->
<form method="POST" action="/submit">
    <input name="title" />
    <button type="submit">Save</button>
</form>

The fix is embarrassingly simple:

<!-- ✅ Just add @csrf -->
<form method="POST" action="/submit">
    @csrf
    <input name="title" />
    <button type="submit">Save</button>
</form>

The @csrf directive outputs a hidden input field:

<input type="hidden" name="_token" value="abc123..." />

I’ve been working with Laravel since version 5 and I still forget this sometimes. You’re not alone.


Reason 2: Your Session Expired

This one is less a bug and more of a security feature working as intended.

You open a form, grab coffee, get distracted by Slack, answer emails, come back 2 hours later, and hit submit. 419.

Laravel sessions have a lifetime (default: 120 minutes, configured in config/session.php). Once the session expires, the CSRF token stored in it is gone. The form still has the old token in its hidden field, but Laravel’s session has a new (or no) token. Mismatch → 419.

The fix: Refresh the page and resubmit. A new session and token are generated on page load.

Don’t extend session lifetime just to avoid this. That short session window is a security feature. If someone walks away from their computer, you want the session to expire. The minor inconvenience of refreshing a form is worth the security.


Reason 3: AJAX Requests Without the CSRF Token

If you’re making AJAX requests (Axios, fetch, jQuery), Laravel also expects a CSRF token. Without it, any non-GET request will get a 419.

The fix: Pass the token in a request header.

If you’re using Axios (which ships with Laravel by default), add this to your resources/js/bootstrap.js:

import axios from 'axios';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

// This reads the CSRF token from the meta tag
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (token) {
    axios.defaults.headers.common['X-CSRF-TOKEN'] = token;
}

And make sure your layout has the CSRF meta tag in <head>:

<meta name="csrf-token" content="{{ csrf_token() }}">

For fetch API:

fetch('/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        'X-Requested-With': 'XMLHttpRequest',
    },
    body: JSON.stringify({ name: 'Amirul' }),
});

For jQuery, Laravel already sets up the token automatically if you include the meta tag.


Reason 4: Caching Issues (the Sneaky One)

Your form works. You swear you added @csrf. But after deploying, some users report 419 errors.

This often happens with aggressive caching. If your server caches the full HTML response including the CSRF token, users might receive a page with a token that doesn’t match their session.

The fix: Don’t cache pages with CSRF tokens. If you must cache, exclude the token from the cached output, or use shorter cache TTLs for form pages.


Reason 5: You’re Behind a Load Balancer or Reverse Proxy

If your app sits behind Nginx, Cloudflare, or a load balancer, HTTPS might be terminated before reaching Laravel. This means Laravel thinks the request is HTTP even though the user is on HTTPS. CSRF tokens are tied to the session, and if the session cookie’s secure flag conflicts, you get mismatches.

The fix: Trust the proxy in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

Or specifically in older Laravel versions, configure TrustProxies middleware.


Excluding Routes from CSRF Protection

Sometimes you legitimately need to disable CSRF for specific routes. The classic example: third-party webhooks.

Imagine a payment gateway that sends a POST request to your app. They’re not going to include a CSRF token — they don’t even know what your token is. Your VerifyCsrfToken middleware blocks the request, and the payment never processes. Not great.

The solution: add the webhook URL to the $except array in app/Http/Middleware/VerifyCsrfToken.php:

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array<int, string>
     */
    protected $except = [
        '/webhook/payment-gateway',
        '/webhook/github',
        '/api/external/*',  // wildcards work too
    ];
}

A few important caveats:

  • Never empty the $except array entirely (protected $except = ['*']). That disables CSRF protection for your entire app. You might as well leave your front door unlocked.
  • Only exclude routes you don’t control the request origin for — webhooks, third-party callbacks, etc.
  • If you control both the frontend and backend, you should never need to exclude routes. Fix the root cause instead.

For Laravel 13 Specifically

The VerifyCsrfToken middleware hasn’t fundamentally changed in Laravel 13. The middleware configuration flow in bootstrap/app.php is a bit different (using ->withMiddleware()), but CSRF is still handled the same way.

One thing that is different: if you’re using the new #[Scope] attribute-based scopes or other Laravel 13 patterns in your forms, the CSRF token is still required for any state-changing request. No exceptions.

Also, if you’re building a SPA with Laravel 13 as the backend, you should strongly consider using Laravel Sanctum for authentication instead of session-based auth. Sanctum handles CSRF differently (it uses a cookie-based approach with X-XSRF-TOKEN), which avoids the 419 problem for SPAs entirely.


Quick Checklist: Did You Get a 419?

Before you start debugging for an hour, run through this:

  • Is @csrf inside every <form> that uses POST, PUT, PATCH, or DELETE?
  • For AJAX requests — is X-CSRF-TOKEN in the request headers?
  • Did the user leave the page open for 2+ hours? (Refresh and resubmit)
  • Is your server caching pages with CSRF tokens? (Don’t do that)
  • Are you behind a proxy/load balancer? (Trust the proxy)
  • Is this a third-party webhook? (Add to $except if yes)

TL;DR

Error 419 means your CSRF token didn’t validate. 90% of the time, you forgot @csrf in a form. The other 10% is session expiry (refresh the page), AJAX missing the header, or a webhook that needs to be excluded from CSRF in VerifyCsrfToken.php.

It’s annoying, but it’s protecting your users. Don’t disable it globally. Fix the broken form instead. ✌️