Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · March 06, 2025 · 9 min read · 10 Views
Banner: Laravel 419 Page Expired error behind an HTTPS load balancer with two app servers

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:

  1. 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.
  2. Submit the form. Round-robin sends the POST to node B.
  3. Node B has no session with that ID in its local storage/framework/sessions directory, 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_LIFETIME was 120 minutes and users were failing within seconds of loading the form.
  • Different APP_KEY per 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:

  1. File sessions on each node. SESSION_DRIVER=file stores sessions on local disk. With two nodes and no stickiness, a session created on one node is invisible to the other.
  2. Untrusted proxy. Laravel did not trust the load balancer, so it ignored X-Forwarded-Proto: https, believed every request was HTTP and generated http:// URLs. Some form actions built with route() pointed to http://book.example.com/....
  3. Secure session cookie. We had set SESSION_SECURE_COOKIE=true. A form posting to an http:// action does not send a Secure cookie, 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 not request()->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

  • TokenMismatchException entries (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_for and 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_DRIVER is file while 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 file session driver on more than one node.
  • Keep APP_KEY identical across nodes and stored in a secrets manager.
  • Leave SESSION_DOMAIN unset unless you deliberately share sessions across subdomains.
  • Monitor TokenMismatchException volume; a sudden jump is an infrastructure signal.

References

Add a label

#laravel #php #csrf #sessions #load-balancer
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.