The problem: A Laravel project using both Inertia.js (for most pages) and Livewire (for a few embedded widgets) throws intermittent 419 Page Expired errors that don't correlate with actual session timeout.
Environment: Laravel 11, Inertia.js 1.x, Livewire 3.x, PHP 8.3
The Error
POST /livewire/update 419 (unknown status)
POST /some-inertia-route 419 (unknown status)
TokenMismatchException: CSRF token mismatch.
Why It Happens
Inertia and Livewire each manage their own CSRF token refresh independently, and when both are active on the same page, they can end up fighting over which token is "current." Specifically:
1. Livewire refreshes the CSRF token in the session on every request (part of its own request lifecycle), but the token embedded in Inertia's initial page load (via the <meta name="csrf-token"> tag, read once at boot by Axios) never updates itself after that first render.
2. If a Livewire component fires first and rotates the session's CSRF token, the next Inertia request — still using the old token cached in Axios's default headers from page load — gets rejected with a 419, even though the session itself hasn't actually expired.
3. This is worse on long-lived SPA-style pages (a hallmark of Inertia) where the user never does a full page reload, so Inertia's cached token can drift out of sync with the session for the entire visit if a Livewire widget on the same page touches the CSRF token in the background.
The Fix
The cleanest fix is to have Inertia refresh its CSRF token from each response, rather than trusting the one read at initial page load. Add a global Axios response interceptor that keeps the token in sync:
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.interceptors.response.use(
response => {
const newToken = response.headers['x-csrf-token'];
if (newToken) {
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) meta.setAttribute('content', newToken);
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = newToken;
}
return response;
},
error => Promise.reject(error)
);
Then have a middleware attach the current token to every response header so the interceptor above has something to read:
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-CSRF-TOKEN', csrf_token());
return $response;
}
Also add a global Inertia 419 handler so if a stale token ever does slip through, the user gets a graceful reload instead of a confusing error page:
router.on('invalid', (event) => {
if (event.detail.response.status === 419) {
window.location.reload();
}
});
Step-by-Step
- Register the
RefreshCsrfHeadermiddleware in yourwebmiddleware group so it runs on both Inertia and Livewire routes. - Add the Axios response interceptor in
bootstrap.jsto keep the meta tag and default header in sync. - Add the Inertia
router.on('invalid', ...)fallback handler for any 419 that still slips through. - Rebuild assets:
npm run build. - Reproduce the original scenario — interact with a Livewire widget, then trigger an Inertia navigation — and confirm no 419 appears.
- Check
storage/logs/laravel.logfor any remainingTokenMismatchExceptionentries to confirm the fix covers all routes.
Edge case: If you're also using Laravel Sanctum's SPA authentication in the same app, Sanctum's own CSRF cookie (XSRF-TOKEN) is a separate mechanism from the session's _token value used above — mixing Inertia, Livewire, and Sanctum together means you may need to sync both tokens, not just the one shown here, depending on which guard handles which route.
Still seeing random 419s after applying this? Reach out on the Contact page and I'll help trace your specific setup.