Laravel 419 Page Expired after moving behind an HTTPS load balancer
Login and checkout forms started failing with 419 Page Expired the day we put two Laravel nodes behind a TLS-terminating load balancer. Untrusted proxy headers, secure cookies and file sessions on each node combined into one confusing error.
Our booking platform for physiotherapy clinics ran for a year on a single VM with nginx and PHP-FPM terminating TLS itself. In March we moved it to two app nodes behind a managed load balancer that terminates HTTPS and forwards plain HTTP on port 80. Within an hour, support had tickets from clinic staff: "Page Expired" when logging in, and the same error when saving appointments.
The browser showed Laravel's default error page with status 419 and the text "Page Expired".
The application log was silent, because Laravel does not report TokenMismatchException by default.
We told it to stop ignoring that exception for a day, and the log filled up:
// bootstrap/app.php (temporary, while debugging)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->stopIgnoring(\Illuminate\Session\TokenMismatchException::class);
})
[2025-03-04 08:12:47] production.ERROR: CSRF token mismatch. {"exception":"[object] (Illuminate\\Session\\TokenMismatchException(code: 0): CSRF token mismatch."}
Not every request failed. Roughly 40 percent of form submissions returned 419, and some users succeeded on the second or third try.
Reproducing it
The pattern became clear once we logged which node served each request:
- Load
https://book.example.com/login. The load balancer sends you to node A, which starts a session and embeds a CSRF token in the form. - Submit the form. Round-robin sends the POST to node B.
- Node B has no session with that ID in its local
storage/framework/sessionsdirectory, starts an empty one, and the token does not match. 419.
Forcing both requests to one node with a hosts-file entry always succeeded, but only if we connected over HTTPS. Over plain HTTP to the node, login also failed, which pointed at a second problem.
What we ruled out
- Session lifetime.
SESSION_LIFETIMEwas 120 minutes and users were failing within seconds of loading the form. - Different
APP_KEYper node. A mismatched key also produces 419, because the encrypted session cookie cannot be decrypted. We compared the hash of the key on both nodes; they matched. - Browser cache serving an old token. The login page sends
Cache-Control: no-cache, private, and hard reloads did not help.
Root cause
Three configuration gaps that a single TLS-terminating server had hidden:
- File sessions on each node.
SESSION_DRIVER=filestores sessions on local disk. With two nodes and no stickiness, a session created on one node is invisible to the other. - Untrusted proxy. Laravel did not trust the load balancer, so it ignored
X-Forwarded-Proto: https, believed every request was HTTP and generatedhttp://URLs. Some form actions built withroute()pointed tohttp://book.example.com/.... - Secure session cookie. We had set
SESSION_SECURE_COOKIE=true. A form posting to anhttp://action does not send aSecurecookie, so the session was missing even on the right node.
What is happening under the hood
Laravel's CSRF protection compares the _token field (or the X-CSRF-TOKEN / X-XSRF-TOKEN header)
with the token stored in the user's session. 419 does not mean the token is wrong in an absolute sense;
it means the server could not find a session containing that token. Anything that loses the session
between the GET that rendered the form and the POST that submits it produces the same error.
Behind a proxy, the application sees the proxy's connection, not the browser's. The original scheme,
host and client IP arrive in X-Forwarded-* headers. Symfony's request object, which Laravel uses,
ignores those headers unless the proxy's address is trusted, because otherwise any client could claim
to be on HTTPS or spoof its IP.
| Symptom | Cause |
|---|---|
| 419 on random requests | Session stored on another node's disk |
419 every time over a link generated by route() |
http:// URL, Secure cookie not sent |
| Mixed content warnings for assets | asset() also generated http:// URLs |
request()->ip() shows the LB address in logs |
Proxy not trusted |
Environment
| Component | Version / value |
|---|---|
| OS | Ubuntu 24.04 LTS on both nodes |
| PHP | 8.3.17, PHP-FPM behind nginx 1.24 |
| Framework | Laravel 11.43 |
| Load balancer | Managed L7 load balancer, TLS terminated, round-robin, no stickiness |
| Session config | SESSION_DRIVER=file, SESSION_SECURE_COOKIE=true, SESSION_DOMAIN unset |
| Cache / queue | Redis 7.2 (already shared, used only for cache) |
Minimal reproduction
// bootstrap/app.php (Laravel 11, before the fix)
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php')
->withMiddleware(function (Middleware $middleware) {
// no trustProxies() call: X-Forwarded-Proto is ignored
})
->create();
# From inside the private network, pretend to be the load balancer
curl -s -H 'X-Forwarded-Proto: https' -H 'Host: book.example.com' \
http://10.0.1.11/login | grep -o 'action="[^"]*"'
# action="http://book.example.com/login" <- should be https
Who is affected
Any Laravel app behind a reverse proxy, load balancer, CDN or Kubernetes ingress that terminates TLS,
and any multi-node deployment using the file or array session driver. Single-node apps behind
nginx on the same host usually do not see it because the request already arrives as HTTPS.
The fix
1. Trust the load balancer. In Laravel 11 this moved from the TrustProxies middleware class to
bootstrap/app.php. We trusted only our private subnet, not every address:
// bootstrap/app.php
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: ['10.0.0.0/16'],
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO,
);
})
Our load balancer connects from addresses inside 10.0.0.0/16 and the nodes have no public
interface, so no client can reach PHP directly with forged headers.
2. Move sessions to a shared store. Redis was already there for cache, so we added a separate connection database for sessions so a cache flush would not log everyone out:
SESSION_DRIVER=redis
SESSION_CONNECTION=session
SESSION_SECURE_COOKIE=true
SESSION_DOMAIN=null
// config/database.php (excerpt)
'redis' => [
// ...
'session' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '2'),
],
],
The database session driver would also work; we picked Redis because it was already
highly available and sessions are short-lived.
3. Keep SESSION_DOMAIN unset unless you need subdomains. We briefly tried
SESSION_DOMAIN=.example.com while debugging. It made the cookie visible to every subdomain,
including our marketing site, and browsers that still held the old host-only cookie now sent two
cookies with the same name, which caused its own 419s until they expired. Leaving it null scopes the cookie to the exact host.
4. Deploy with the same APP_KEY on every node, from the secrets manager, never generated per
host. We already had this, but it went into the deploy checklist.
Alternatives and trade-offs
- Sticky sessions on the load balancer. It hides the file-session problem, but sessions vanish when a node is replaced, and uneven stickiness skews load. Good as a stopgap, not a fix.
- Trusting
*.trustProxies(at: '*')is common in tutorials and acceptable when the app is unreachable except through the proxy. If the nodes are ever exposed, clients can spoof their IP and scheme, which affects rate limiting and audit logs. - Forcing HTTPS in code with
URL::forceScheme('https'). It fixes generated URLs but notrequest()->secure()or client IPs, so we used it only as a belt-and-braces setting in production.
How to test the fix
# Generated URLs now use https when the LB says so
curl -s -H 'X-Forwarded-Proto: https' -H 'Host: book.example.com' \
http://10.0.1.11/login | grep -o 'action="[^"]*"'
# action="https://book.example.com/login"
# Session created on node A is readable on node B
php artisan tinker --execute="dump(config('session.driver'));"
A feature test guards the proxy configuration:
public function test_forwarded_https_is_trusted(): void
{
$response = $this->withServerVariables(['REMOTE_ADDR' => '10.0.3.7'])
->withHeaders(['X-Forwarded-Proto' => 'https', 'X-Forwarded-Host' => 'book.example.com'])
->get('/login');
$response->assertOk()->assertSee('action="https://book.example.com/login"', false);
}
We also ran a 200-iteration script that loaded the login form and submitted it through the load
balancer with a cookie jar; all 200 returned 302 to the dashboard.
Confirming in production
TokenMismatchExceptionentries (we kept reporting them on) went from about 1,100 per day to 4 per day, which matches genuinely expired forms left open overnight.- Redis showed session keys growing to roughly 6,500, which matched our daily active staff count.
- Nginx access logs on both nodes showed the real client IP in
$http_x_forwarded_forand Laravel's logs now recorded the same IP instead of the load balancer address. - The browser's Security panel reported no mixed content on any page.
Regression guard
- The feature test above runs in CI.
- A deploy-time check fails the release if
SESSION_DRIVERisfilewhile the target environment has more than one node. - A synthetic monitor logs in through the load balancer every five minutes from two regions.
Security implications
Trusting proxy headers is a security decision. Trust only the addresses your load balancer actually
uses, and make sure application nodes cannot be reached directly, otherwise attackers can spoof
X-Forwarded-For to bypass IP-based rate limits or poison audit logs. Moving sessions to Redis means
session data now crosses the network: we use a password-protected Redis on a private subnet with TLS
between nodes and Redis. Keep SESSION_SECURE_COOKIE=true and http_only on; do not disable CSRF
checks to make 419 go away.
Prevention checklist
- Any time TLS termination moves to a proxy, configure trusted proxies in the same change.
- Never use the
filesession driver on more than one node. - Keep
APP_KEYidentical across nodes and stored in a secrets manager. - Leave
SESSION_DOMAINunset unless you deliberately share sessions across subdomains. - Monitor
TokenMismatchExceptionvolume; a sudden jump is an infrastructure signal.
References
- Laravel 11 docs: Configuring Trusted Proxies — https://laravel.com/docs/11.x/requests#configuring-trusted-proxies
- Laravel 11 docs: HTTP Session configuration — https://laravel.com/docs/11.x/session
- Laravel 11 docs: CSRF Protection — https://laravel.com/docs/11.x/csrf
- Symfony docs: How to Configure Symfony to Work behind a Load Balancer or a Reverse Proxy — https://symfony.com/doc/current/deployment/proxies.html
Add a label
Embed this post
<iframe src="https://hibug.me/laravel-419-page-expired-behind-load-balancer" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.