Laravel 419 Page Expired: Every CSRF Fix You Will Ever Need
You submit a form. Laravel slaps you with a 419 Page Expired error. You have no idea what changed or what went wrong. Sound familiar? This guide walks through every single cause of the Laravel 419 error and gives you concrete fixes for each one.
TL;DR: The 419 error means CSRF token mismatch. Three things to check right now:
- Is
@csrfin your form? - Is your session driver working? (Check
storage/framework/sessionspermissions or Redis connection.) - Does
SESSION_DOMAINin your.envmatch the URL you are visiting?
If all three are correct, keep reading. The fix is in this guide.
What is CSRF and Why Does Laravel Enforce It?
CSRF stands for Cross-Site Request Forgery. It is an attack where a malicious website tricks your browser into making a request to a different site where you are already authenticated. Imagine you are logged into your banking app. You visit a shady website that has a hidden form pointing to your bank's transfer endpoint. Your browser happily sends the request along with your session cookies, and suddenly money is moving without your consent.
Laravel prevents this by generating a unique token for every active user session. This token gets embedded in every form and must be sent back with every state-changing request (POST, PUT, PATCH, DELETE). The VerifyCsrfToken middleware compares the token in the request against the one stored in the session. If they do not match, Laravel returns HTTP 419 Page Expired.
This is not Laravel being annoying. This is Laravel protecting your users. The 419 error means something broke in the token verification chain, and we need to figure out where.
Step 1: The Missing @csrf Directive
This is the number one cause of the 419 error, especially for developers new to Laravel. Every HTML form that makes a POST request needs to include the CSRF token. Laravel makes this dead simple with the @csrf Blade directive.
Here is what a correct form looks like:
<form method="POST" action="/contact">
@csrf
<input type="text" name="name" placeholder="Your name">
<input type="email" name="email" placeholder="Your email">
<textarea name="message"></textarea>
<button type="submit">Send</button>
</form>
The @csrf directive generates a hidden input field that looks like this:
<input type="hidden" name="_token" value="a1b2c3d4e5f6...">
If you are writing raw HTML (not Blade), you need to add this hidden field manually using the csrf_token() helper:
<input type="hidden" name="_token" value="<?php echo csrf_token(); ?>">
Quick check: View your page source in the browser. Search for _token. If it is not there, that is your problem.
Step 2: Session Not Persisting
The CSRF token lives in the session. If the session is not being stored or retrieved properly, every request looks like a fresh visitor with no token to validate against. There are two common sub-problems here.
File Session Driver: Permission Issues
By default, Laravel uses the file session driver, which stores sessions in storage/framework/sessions. If your web server cannot write to this directory, sessions silently fail.
Run this from your project root:
ls -la storage/framework/sessions/
The directory should be writable by your web server user (usually www-data on Ubuntu or nginx on CentOS). Fix permissions with:
chmod -R 775 storage/framework/sessions
chown -R www-data:www-data storage/framework/sessions
Also confirm the directory actually exists. After a fresh git clone, the storage subdirectories are often missing because they are in .gitignore. Run:
php artisan storage:link
mkdir -p storage/framework/{sessions,views,cache}
Redis or Database Session Driver: Connection Issues
If your .env has SESSION_DRIVER=redis or SESSION_DRIVER=database, the session store depends on that backing service being available.
For Redis, verify the connection:
php artisan tinker
>>> Illuminate\Support\Facades\Redis::ping()
# Should return "PONG"
If Redis is not reachable, every request creates a new session and the token never persists. Check your REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD in .env.
For the database driver, make sure you have actually created the sessions table:
php artisan session:table
php artisan migrate
Then check that the sessions table exists and is populated after you visit a page.
Step 3: Cookie Domain Mismatch
This one is sneaky and catches a lot of people during deployment. Laravel sends the session cookie with a specific domain scope. If the domain in the cookie does not match the URL the user is visiting, the browser will not send the cookie back, and the session is effectively lost.
Check your .env:
SESSION_DOMAIN=.yourapp.com
Here are common mistakes:
- Missing the leading dot:
SESSION_DOMAIN=yourapp.comwill NOT work forwww.yourapp.com. Add the leading dot to cover all subdomains:.yourapp.com. - Using localhost in production: If
SESSION_DOMAINis set tolocalhostand you deploy toyourapp.com, sessions will never persist. - Mismatch with APP_URL: If
APP_URL=https://yourapp.combut you visithttp://www.yourapp.com, the cookie might not be sent depending on yourSESSION_SECURE_COOKIEsetting.
SESSION_SECURE_COOKIE and HTTPS
If SESSION_SECURE_COOKIE=true in your .env, the session cookie is only sent over HTTPS. If your site is not using HTTPS (or your load balancer terminates SSL and forwards plain HTTP to Laravel), the cookie never reaches the server.
For local development without HTTPS:
SESSION_SECURE_COOKIE=false
For production with HTTPS (which should be always):
SESSION_SECURE_COOKIE=true
If you are behind a load balancer that terminates SSL, you also need to tell Laravel to trust the proxy. More on that in Step 4.
Step 4: Load Balancer and Multi-Server Setups
This is where things get interesting in production. If you have multiple application servers behind a load balancer, you have a session affinity problem. User A submits a form. The POST request lands on Server B, which has no idea about the session that was created on Server A. Token mismatch. 419.
Option A: Sticky Sessions
Configure your load balancer to route all requests from the same client to the same backend server. In AWS ALB, this is called "stickiness" and it is a checkbox in the target group settings. In Nginx, use the ip_hash directive.
Sticky sessions are simple but fragile. If a server goes down, all sessions pinned to it are lost.
Option B: Centralized Session Store (Recommended)
The better approach is to use a shared session store that all servers can access. Redis is the go-to choice:
# .env
SESSION_DRIVER=redis
REDIS_HOST=your-redis-host.elasticache.amazonaws.com
REDIS_PORT=6379
REDIS_PASSWORD=null
Now every server reads and writes sessions from the same Redis instance. No stickiness needed, and sessions survive server restarts.
Trusting the Proxy
When your app sits behind a load balancer or reverse proxy, the incoming request appears to come from the proxy's internal IP, not the user's real IP. It also appears to be plain HTTP even if the user connected via HTTPS. This confuses Laravel's session and cookie logic.
In app/Http/Middleware/TrustProxies.php (or TrustProxies in Laravel 11+), set:
protected $proxies = '*'; // Trust all proxies (or list specific IPs)
protected $headers = Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO;
Without this, Laravel might generate HTTPS URLs but set cookies as if the connection were HTTP, causing mismatches. If you are running behind AWS ALB, Cloudflare, or any reverse proxy, you almost certainly need this. For a deeper dive into related proxy and header issues, see our guide on fixing CORS preflight request blocked errors.
Step 5: SPA and AJAX Requests
Single Page Applications and AJAX calls do not submit traditional HTML forms, so the @csrf hidden field approach does not apply. Instead, you send the CSRF token as a request header.
The Meta Tag Approach
First, add the token to a meta tag in your main layout:
<meta name="csrf-token" content="{{ csrf_token() }}">
Axios (Laravel Default)
If you are using axios (which ships with Laravel's default JavaScript scaffolding), the token is already configured in resources/js/bootstrap.js:
window.axios.defaults.headers.common['X-CSRF-TOKEN'] =
document.querySelector('meta[name="csrf-token"]').getAttribute('content');
If this line exists and axios is loaded, you are good. If you removed or skipped the bootstrap file, add it back.
Fetch API
For native fetch calls, you need to include the header manually:
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
'Accept': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});
jQuery AJAX
If you are using jQuery, set up a global AJAX header:
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
Laravel Sanctum for SPAs
If you are building a SPA with a separate frontend (React, Vue, etc.) that talks to a Laravel API backend, use Laravel Sanctum's cookie-based authentication. Before making any authenticated requests, hit the CSRF cookie endpoint:
// First, get the CSRF cookie
await axios.get('/sanctum/csrf-cookie');
// Now you can make authenticated requests
await axios.post('/api/login', { email, password });
The /sanctum/csrf-cookie endpoint sets an XSRF-TOKEN cookie. Axios automatically reads this cookie and sends it as an X-XSRF-TOKEN header on subsequent requests. No manual token management needed.
Step 6: Session Lifetime Too Short
The default session lifetime in Laravel is 120 minutes (2 hours). If a user opens a form, goes to lunch, and comes back 3 hours later to submit it, the session has expired and the CSRF token is invalid.
Check your config/session.php or .env:
SESSION_LIFETIME=120
For most applications, 120 minutes is reasonable. But if your users regularly leave forms open for extended periods (think long survey forms, admin dashboards, or content editors), consider increasing it:
SESSION_LIFETIME=480 # 8 hours
There is a tradeoff here. Longer session lifetimes mean sessions consume more storage and there is a wider window for session hijacking. Do not set it to something absurd like 525600 (one year) just to avoid the 419 error.
A better approach for long-running forms is to refresh the token periodically via JavaScript. Set up an interval that hits a keep-alive endpoint every 15 minutes to prevent the session from expiring while the user is actively working. If you are seeing 500 errors alongside the 419 during these kinds of session issues, check our Laravel 500 error troubleshooting guide for additional debugging steps.
Step 7: Cloudflare Caching HTML Pages
This one trips up so many teams and is genuinely hard to debug. Here is the scenario: you put Cloudflare in front of your Laravel app. You create a Page Rule with "Cache Everything" to boost performance. Suddenly, random users start getting 419 errors on form submissions.
What happened? Cloudflare cached an HTML page that contains a CSRF token embedded in the form. Now every visitor who gets the cached page receives the same stale token. When they submit the form, the token does not match their individual session, and Laravel rejects it.
The fix depends on your caching strategy:
Option A: Do Not Cache HTML at All
The simplest approach. Set the appropriate header in your Laravel middleware or web server config:
Cache-Control: no-store, no-cache, must-revalidate
Or in Laravel middleware:
return $next($request)
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->header('Pragma', 'no-cache');
Option B: Cache Static Pages, Bypass Dynamic Ones
In Cloudflare, create Page Rules or Cache Rules that bypass the cache for any URL that serves forms or requires sessions. Cache only truly static assets (images, CSS, JS, fonts) and marketing pages that have no forms.
Option C: Lazy-Load the Token
Render the page without the CSRF token. After the page loads, make an AJAX call to fetch a fresh token and inject it into the form. This way, even if the HTML is cached, the token is always fresh. This is more work but lets you cache aggressively.
Is Your App Leaking Session Data?
Misconfigured headers and exposed session cookies are common attack vectors. Run a free scan to check your application's security posture.
Scan Your App FreePro Tip: APIs vs SPAs vs Traditional Forms
Not every request needs CSRF protection. Understanding when to use what saves you a lot of headaches:
- Traditional Blade forms: Use
@csrfin every form. This is the standard approach. - SPAs with Sanctum: Use the
/sanctum/csrf-cookieendpoint for cookie-based auth. Axios handles the token automatically. - Stateless APIs with token auth: If your API uses Sanctum API tokens (Bearer tokens) or Passport, CSRF is not needed. Token-based auth is inherently immune to CSRF because the token must be explicitly included in the request. Exclude these routes from CSRF verification.
To exclude specific routes from CSRF verification, add them to the $except array in VerifyCsrfToken middleware:
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'api/*', // All API routes
'webhook/stripe', // Webhook endpoints
'webhook/github',
];
In Laravel 11 with the slim skeleton, configure this in bootstrap/app.php instead:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'api/*',
'webhook/*',
]);
})
Only exclude routes that use their own authentication mechanism. Never disable CSRF globally just because you got a 419 error. That is fixing the alarm by removing the smoke detector.
Session Driver Comparison
Choosing the right session driver matters for reliability and performance. Here is a comparison:
| Driver | Pros | Cons | Best For |
|---|---|---|---|
| file | Zero config, works out of the box, no external dependencies | Does not scale to multiple servers, filesystem I/O can be slow, disk fills up without cleanup | Local development, single-server apps |
| cookie | No server-side storage needed, survives server restarts | 4KB size limit, session data visible to client (encrypted but still), more bandwidth per request | Stateless apps with minimal session data |
| database | Shared across servers, queryable, reliable with existing DB | Adds DB load on every request, needs migration, slower than in-memory stores | Apps already DB-heavy, small to medium traffic |
| redis | Very fast, shared across servers, built-in TTL for automatic cleanup | Requires Redis server, another service to maintain, data lost if Redis restarts without persistence | Production multi-server deployments (recommended) |
| memcached | Fast, shared across servers, simple | No persistence (data lost on restart), no built-in TTL management like Redis, less feature-rich | High-traffic apps where session loss on restart is acceptable |
For any production environment with more than one application server, Redis is the recommendation. It solves session sharing, it is fast, and it handles expiration automatically.
Common Mistakes That Cause 419 Errors
Here is a quick checklist of the most frequently seen mistakes. If you have been reading this guide and still have not found your issue, one of these is probably it:
- Using GET for form submissions that should be POST. Laravel only checks CSRF tokens on POST, PUT, PATCH, and DELETE. If your form uses
method="GET", the@csrftoken is ignored. But if you later change to POST without adding@csrf, you get 419. - Clearing the config cache without clearing the session cache. Running
php artisan config:cachewithoutphp artisan cache:clearcan leave stale session configuration that does not match the new.envvalues. - Deploying new code without restarting queue workers. Queue workers run in a long-lived process. After deployment, they still hold the old encryption key and session config. Run
php artisan queue:restartafter every deploy. - Mismatched APP_KEY across servers. If each server in your cluster has a different
APP_KEY, sessions encrypted on one server cannot be decrypted on another. Make sure all servers share the same.envor use a centralized secret manager. - Browser extensions blocking cookies. Privacy-focused extensions like uBlock Origin or cookie auto-delete extensions can strip the session cookie. Test in incognito mode with extensions disabled to rule this out.
- Running php artisan config:cache in local development. This caches the
.envvalues. If you changeSESSION_DRIVERorSESSION_DOMAINin.envafter caching, the changes are invisible to Laravel. Runphp artisan config:clearafter any.envchange.
Frequently Asked Questions
Why does Laravel show 419 Page Expired on every form submission?
The most common cause is a missing @csrf directive inside your Blade form. Laravel's VerifyCsrfToken middleware rejects any POST request that does not include a valid _token field matching the session. Add @csrf right after your opening <form> tag and the error should disappear. If the token is present, check that your session driver is working (file permissions, Redis connection, or database table) and that SESSION_DOMAIN matches your URL.
How do I send the CSRF token in AJAX and fetch requests?
Add a meta tag in your layout head: <meta name="csrf-token" content="{{ csrf_token() }}">. Then read it from JavaScript and attach it as an X-CSRF-TOKEN header. If you use axios, Laravel's default bootstrap.js already configures this automatically. For fetch calls, read the meta tag content and include it in the headers object of every POST, PUT, PATCH, or DELETE request. See Step 5 above for complete code examples.
Can Cloudflare caching cause a Laravel 419 error?
Yes, absolutely. If Cloudflare caches an HTML page that contains a CSRF token, every visitor receives the same stale token. When they submit the form, the token does not match their unique session, triggering a 419 error. Fix this by adding a Cache-Control: no-store header to all dynamic pages, or by creating a Cloudflare Page Rule that bypasses cache for your application routes. See Step 7 for detailed solutions.
Check Your Application Security
Session misconfigurations are just one piece of the puzzle. Run a free scan to check for exposed files, missing security headers, and other vulnerabilities.
Run Free Security ScanThe Bottom Line
The Laravel 419 Page Expired error always comes back to one thing: the CSRF token in the request does not match what the server expects. The token could be missing, the session could be broken, the cookie might not reach the server, or a caching layer might be serving stale HTML. Work through the steps in this guide from top to bottom, and you will find your issue.
Start with the simple stuff. Check for @csrf. Check your session driver. Check SESSION_DOMAIN. Nine times out of ten, the fix is in those first three steps. For the remaining cases, you now have the tools to diagnose load balancer problems, SPA token handling, session lifetime issues, and Cloudflare caching conflicts.
Do not disable CSRF protection to fix the error. Find the root cause and fix it properly. Your users' security depends on it.
Related guides: Laravel 500 Internal Server Error Fix, CORS Preflight Request Blocked Fix. Related tools: Exposure Checker, SSL Checker, CSP Builder, JWT Decoder, and 70+ more free tools.
Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.