Livewire: This page has expired - but what if I don't want it to
If you are like me and many other Laravel devs, you've probably run into this error before. Without batting an eye you also probably just refreshed and ignored it, but leaving this error could cause some significant headaches for your users down the road. I am going to walk through what causes this error, why you shouldn't just ignore it, and some ways you can permanently resolve it.
The Problem
The page expired error is in contention for one of the most common errors someone can encounter when dealing with Livewire or even Laravel in general. But not too many people know what causes it and very few know how to actually resolve it safely.
Let's begin by walking through the issue. First you create a nice Livewire component - maybe a form or some other interactive component on your app or site. Next you go to test it out, and it works perfectly. Time to make some coffee! You step away, and when you come back you realize you didn't test a certain use case. So you attempt to fill out the form again and submit it, but this time you are greeted by a browser alert "This page has expired. Would you like to refresh the page?"
What happened here? Did the page know you went to go make coffee and decided to rebel in that time? Not likely, unless you have a sentient AI embedded on your site. Instead, this is a security feature. Laravel and by extension Livewire have this concept of signing requests. This helps prevent a common type vulnerability known as Cross Site Request Forgery. When you site is vulnerable to CSRF it allows an attacker to submit requests (usually using the POST or PUT HTTP methods) while not actually being on your site. This means that if you have a contact form without CSRF protection on it, someone could write a simple shell script to repeatedly hammer that form with malicious requests submitting as many times as they want.
The solution? CSRF tokens. A CSRF token is cryptographically generated by the server and then passed down to the client - commonly by embedding it in a meta tag or a hidden input field. This token is unique to the user's session and shows the user's request has been preapproved by the server. The docs say this a little more concisely:
Laravel automatically generates a CSRF "token" for each active user session managed by the application. This token is used to verify that the authenticated user is the person actually making the requests to the application. Since this token is stored in the user's session and changes each time the session is regenerated, a malicious application is unable to access it.
Did you catch that? CSRF tokens are regenerated when a user's session is regenerated. Which explains the behavior I detailed above. Laravel sessions by default expire after 2 hours (120 minutes). And once they expire they take the CSRF token with it.
Normally, this kind of behavior is a good thing. You may have some sort of portal where a user is required to log in and after they log in they can fill out a form. In that case, you want the form to not submit if the user's session expired, and they are effectively logged out. But in the case of using Livewire for everyday sites this can be a rough user experience.
One of our client's for instance hosts a scholarship application form which requires applicants to fill out some pretty long essay-like answers. If they took their time and/or took a break in the middle of filling it out, when they go to submit it they would be greeted by a request to refresh the page and possibly lose their progress. Of course we could fix this by implementing some sort of autosave feature with LocalStorage, etc. But that would still be a really jarring experience to think you are about to lose all your progress.
The Solution
Now that we can clearly see the problem and what causes it, lets look at some solutions which are commonly suggested in response to this issue.
The first suggestion: "Let your user's figure it out." To me this is a non-starter, software should not depend on the user using it the "right way." Filling out a form should not have a count down timer or you lose all your progress. So no, the user should not have to figure it out.
Another solution is to just disable CSRF for all Livewire endpoints. There is a way to do this and a very bad way. Both ways don't actually solve the problem though. It's the equivalent of saying my antivirus is flagging this one program repeatedly; so I am just going to whitelist it. That is not the solution; the solution is to submit a false positive report to the AV company to get them to fix their malware signatures. In the same way, disabling CSRF for even some Livewire components is not a good solution - it opens up a security hole that a savvy attacker can exploit.
Ok, so what are we left with? We can't exclude the CSRF validation, but maybe we can re-issue one when it expires? To be honest, this was my solution when I first started writing this article. But after a late night session of coding it dawned on me, there is no way to ensure the person re-issuing a token is coming from the website. In other words, an attacker could just request a CSRF token and then continue sending requests like normal. We merely added a small extra inconvenience to their nefarious plans.
This is where I realized the true solution: never let the session expire. What if we were able to keep the session alive while the user was on the site? This is not a new concept, a number of CMS' do something along this line. But I think I have come up with the cleanest possible solution for Laravel/Livewire/Statamic.
Here it is:
<?php // bootstrap\app.php
use App\Http\Middleware\KeepAliveSession;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Register the keep alive middleware
$middleware->appendToGroup('web', KeepAliveSession::class);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
<?php // app\Http\Middleware\KeepAliveSession.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class KeepAliveSession
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// This is a heartbeat request, we don't need anything in response
if ($request->uri()->path() === '__heartbeat') {
return response([]);
}
// Finish the rest of the request and get back the content
$response = $next($request);
if (!str_starts_with($request->uri()->path() . '/', 'cp/') && $content = $response->getContent()) {
$app_url = config('app.url');
$session_lifetime = config('session.lifetime');
$csrf_token = $request->session()->token();
// Insert the custom heartbeat script before the tag
// This script schedules a heartbeat request to happen before the session expires
// Then it intercepts all fetch requests and checks to see if it is going to
// the app url, if it is then the heartbeat can be delayed for a bit longer
$response->setContent(str_replace('', <<
const heartbeatInterval = 1000 * 60 * {$session_lifetime} * 0.80;
let heartbeatTimeout;
const heartbeat = () => fetch('/__heartbeat', { method: 'POST', headers: { 'X-CSRF-TOKEN': '{$csrf_token}' } });
const queueHeartbeat = () => {
console.log('Queueing heartbeat', new Date(Date.now() + heartbeatInterval).toLocaleTimeString());
clearTimeout(heartbeatTimeout);
heartbeatTimeout = setTimeout(heartbeat, heartbeatInterval);
};
queueHeartbeat();
window.oldFetch = window.fetch;
window.fetch = async (...args) => oldFetch.apply(this, args).then((response) => {
if (response.url.indexOf('{$app_url}') === 0) {
queueHeartbeat();
}
return response;
})
HTML, $content));
}
return $response;
}
}
Whew! That's a decent chunk. What does it do? Well, if the request URI is __heartbeat
then we just return an empty response. Nothing needs to be done as Laravel refreshes the session lifetime behind the scenes whenever a request with a session cookie comes in.
Next, we process the rest of the request and get the response. We check to make sure the response has content and then do a search/replace on that content to insert a script right before the </head>
tag. This script calculates an interval on which to send out the heartbeat request. The interval is based on the session.lifetime
config value. Then to ensure we aren't sending more requests to the server than needed, we intercept all fetch requests to see if they are to the backend or an external request. If they are to the backend, we can delay the heartbeat for another interval longer as Laravel has refreshed the session lifetime already.
That's it! To fix the page expired error, we just never let it expire in the first place. This remains secure as no one outside of the site can request a new CSRF token. No one can even send a request to the heartbeat endpoint. If you have a backend to your site (like I do here) just exclude that URL using the if statement on Line 25 so the session won't be renewed if you are on the backend of the site.