El error 419 Page Expired aparece cuando Laravel rechaza un envío con POST, PUT, PATCH, DELETE o QUERY porque el token CSRF que llega en la petición no coincide con el que guarda la sesión del usuario. En la mayoría de los casos la solución es una de estas 2: recargar la página y volver a enviar el formulario, porque la sesión ha caducado mientras estaba abierto, o, si el error se repite, comprobar que el formulario lleva la directiva @csrf. Si con eso no basta, el problema suele estar en la cookie de sesión, y en este artículo vamos a ver cómo encontrar la causa exacta y cómo solucionar cada una.
Todo lo que vas a ver está probado en un proyecto nuevo de Laravel 13, reproduciendo los 419 con Chrome y con curl y aplicando después la solución de cada caso. Donde algo cambia en Laravel 10, 11 o 12, te lo indico en su apartado.
¿Qué significa el error 419 Page Expired en Laravel?
El 419 no forma parte del estándar HTTP: en el registro oficial de códigos de estado de la IANA figura como no asignado, y por eso curl lo muestra como 419 unknown status. Laravel lo utiliza para un caso muy concreto, que es el fallo de la protección CSRF, y lo acompaña de su propia página de error con el texto Page Expired.
¿Y qué protege exactamente? La protección CSRF evita que otra web pueda enviar formularios a tu aplicación en nombre de un usuario que tiene la sesión iniciada. Para ello, Laravel genera un token por cada sesión y, en cada petición que modifica datos, comprueba que el token que llega en el campo _token o en las cabeceras X-CSRF-TOKEN o X-XSRF-TOKEN es el mismo que tiene guardado. Si no coinciden, lanza una TokenMismatchException, que se convierte en la respuesta 419.
Fíjate en que, para que esa comprobación salga bien, hacen falta 2 cosas: que la petición lleve el token y que lleve también la cookie de sesión, porque es la que le dice a Laravel en qué sesión tiene que buscarlo. Prácticamente todas las causas del 419 rompen una de las 2, y tenerlo presente te ahorra mucho tiempo a la hora de diagnosticar.
Lo que cambia en Laravel 13
En Laravel 13, el middleware que hace esta comprobación se llama PreventRequestForgery. Hasta Laravel 12 eran VerifyCsrfToken y ValidateCsrfToken, que siguen existiendo como alias obsoletos. Además del nombre, cambia el funcionamiento: antes de mirar el token, el middleware revisa la cabecera Sec-Fetch-Site que envían los navegadores modernos y, si indica que la petición viene del mismo origen, la deja pasar sin comprobar nada más.
Eso sí, según la documentación de Laravel, el navegador solo envía esa cabecera por HTTPS. En la práctica, Chrome también la envía a localhost, porque lo trata como un origen seguro, como explica la referencia de Sec-Fetch-Site de MDN. Si lo pruebas con Chrome, verás que un formulario sin @csrf se envía sin problemas en http://localhost o en HTTPS, y que ese mismo formulario devuelve 419 en cuanto lo abres desde un dominio local por HTTP, como http://miapp.test. En Laravel 12 y anteriores no existe esta comprobación de origen, así que sin token siempre hay 419.
Esto cambia mucho lo que vas a ver en las causas de más abajo. Por HTTPS, una petición del mismo origen pasa aunque le falte el token, aunque la sesión haya caducado o aunque no llegue la cookie de sesión, así que en producción muchos 419 de formularios y de fetch ya no aparecen. Sin embargo, el problema sigue ahí: si la sesión ha caducado o la cookie no llega, el usuario empieza una sesión nueva y pierde lo que tuviera guardado, y los navegadores antiguos, los entornos por HTTP y las peticiones entre subdominios siguen dependiendo del token, así que @csrf sigue siendo necesario. Y si ves un 419 en Laravel 13, lo más probable es que la petición no viniera del mismo origen o no viniera por HTTPS, y eso ya es una pista.
¿Cómo saber qué está provocando el 419?
Antes de cambiar nada, merece la pena mirar la petición que falla, porque la causa casi siempre se ve a simple vista. En las herramientas de desarrollo de Chrome, abre el panel Network, envía el formulario y selecciona la petición que ha devuelto el 419. En la pestaña Payload tiene que aparecer el campo _token, y en la pestaña Cookies, la cookie de sesión de Laravel, cuyo nombre sale de SESSION_COOKIE o, si no la defines, de tu APP_NAME seguido de -session o _session, según la versión con la que se creó el proyecto. Si el navegador ha rechazado alguna cookie, en esa misma pestaña puedes ver cuál y por qué motivo.
El problema es que en producción no tienes delante el navegador del usuario que se queja, y Laravel no escribe los 419 en el log: los ignora por defecto, igual que los 404. Por suerte para nosotros, podemos registrar los datos que necesitamos con un callback en el método withExceptions de bootstrap/app.php:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (HttpException $e, Request $request) {
if ($e->getStatusCode() === 419) {
Log::warning('419 Page Expired', [
'url' => $request->fullUrl(),
'token_sent' => $request->filled('_token')
|| $request->hasHeader('X-CSRF-TOKEN')
|| $request->hasHeader('X-XSRF-TOKEN'),
'session_cookie' => $request->hasCookie(config('session.cookie')),
'sec_fetch_site' => $request->header('Sec-Fetch-Site'),
]);
}
});
})El callback recibe una HttpException con el estado 419, porque Laravel convierte la TokenMismatchException antes de llamar a los callbacks de render. Si tipas la closure con TokenMismatchException, no se ejecuta nunca. Y como no devuelve nada, Laravel sigue respondiendo como siempre y solo deja en el log una línea como esta, que es la de un formulario sin @csrf:
local.WARNING: 419 Page Expired {"url":"http://miapp.test:25196/contacto","token_sent":false,"session_cookie":true,"sec_fetch_site":null}Con esos 3 datos ya sabes por dónde ir:
token_sentafalse: la petición no lleva el token, así que falta@csrfen el formulario o la cabecera en tu código JavaScript.session_cookieafalse: el navegador no ha enviado la cookie de sesión, ya sea por la configuración de la cookie, porque ha caducado o porque ha cambiado laAPP_KEYy Laravel ya no puede descifrarla.Los 2 a
true: llegan el token y la cookie, pero la sesión no contiene ese token. La sesión ya no existe en el servidor, el driver de sesión no la conserva o la página viene de una caché con el token de otro usuario.sec_fetch_siteconsame-siteocross-site: la petición viene de otro subdominio o de otro dominio.
Este callback funciona igual en Laravel 11 y 12, que también configuran las excepciones en bootstrap/app.php.
Las causas del error 419 y cómo solucionarlas
Vamos con las causas, ordenadas de más a menos habituales. En cada una tienes cómo reconocerla y cómo solucionarla.
1. La sesión ha caducado
Es el caso más típico que yo me he encontrado: el usuario abre un formulario, lo deja a medias un buen rato y, cuando vuelve y lo envía, la sesión ya no existe. La duración de la sesión la marca SESSION_LIFETIME, en minutos, que en un proyecto nuevo viene así en el .env:
SESSION_LIFETIME=120Pasa lo mismo cuando cambias la APP_KEY, por ejemplo con php artisan key:generate: Laravel ya no puede descifrar las cookies de sesión que tenían los usuarios, así que todos los formularios que estuvieran abiertos devuelven 419 al enviarse.
En Laravel 13 por HTTPS no verás el 419, como vimos más arriba, pero la sesión anterior se pierde igualmente.
Alargar SESSION_LIFETIME solo retrasa el problema. Una solución mejor es que el usuario no pierda lo que ha escrito: en lugar de la página de error, lo devolvemos al formulario con sus datos y un mensaje que le explique qué ha pasado. Se hace con otro callback en bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (HttpException $e, Request $request) {
if ($e->getStatusCode() !== 419 || $request->expectsJson()) {
return null;
}
return back()
->withInput($request->except('_token', 'password', 'password_confirmation'))
->withErrors(['session' => 'Tu sesión ha caducado. Revisa los datos y vuelve a enviar el formulario.']);
});
})Las peticiones que esperan JSON se quedan con su 419 normal, y fíjate en que quitamos _token y las contraseñas de los datos que guardamos en la sesión. Como la petición que ha fallado ya ha creado una sesión nueva, el formulario al que volvemos lleva un token válido y el segundo envío funciona. En la vista mostramos el mensaje con @error y recuperamos lo escrito con old():
@error('session')
<p>{{ $message }}</p>
@enderror
<form method="POST" action="/contacto">
@csrf
<textarea name="mensaje">{{ old('mensaje') }}</textarea>
<button type="submit">Enviar</button>
</form>Ten en cuenta que esta redirección también oculta un @csrf que falta: el usuario verá el mensaje de sesión caducada en un formulario que no va a funcionar nunca. Por eso es buena idea tenerla junto al callback del log, que te dice cuál ha sido la causa.
2. Al formulario le falta @csrf
Es la siguiente en frecuencia, sobre todo en formularios escritos a mano o copiados de otro proyecto. Cada formulario que envía datos con POST, PUT, PATCH o DELETE tiene que llevar la directiva @csrf, que Blade convierte en un campo oculto _token con el token de la sesión:
<form method="POST" action="/contacto">
@csrf
<textarea name="mensaje"></textarea>
<button type="submit">Enviar</button>
</form>Si usas @method('PUT') o @method('DELETE') para que el formulario se comporte como otro verbo, @csrf sigue siendo necesario: el navegador lo envía como POST, pero Laravel lo trata como PUT o DELETE y comprueba el token igualmente.
Conviene añadirlo siempre, aunque en tu navegador funcione sin él: como vimos en el apartado de Laravel 13, por HTTPS o en localhost la comprobación de origen lo deja pasar, pero en otros entornos dará 419.
3. La cookie de sesión no llega: SESSION_DOMAIN, SESSION_SECURE_COOKIE y SameSite
Si el 419 sale siempre, también en formularios con @csrf recién cargados, lo más probable es que el navegador no esté guardando la cookie de sesión o no la esté enviando. En el log lo verás con session_cookie a false, y hay 3 opciones del .env que lo provocan.
SESSION_SECURE_COOKIE=true marca la cookie como segura, y el navegador solo la acepta por HTTPS. Es lo correcto en producción, pero si copias ese valor a un entorno local por HTTP, Chrome rechaza la cookie y todos los formularios devuelven 419.
SESSION_DOMAIN fija el dominio de la cookie. Con null, el valor por defecto, la cookie es solo del dominio exacto que estás visitando, y si le pones un dominio que no coincide con el de la petición, por ejemplo el de producción mientras trabajas en local, el navegador la rechaza. Además, si tu aplicación responde con y sin www, o envía formularios de un subdominio a otro, la cookie de www.miapp.test no viaja a miapp.test: el formulario se carga con el token de una sesión y se envía con la cookie de otra, o sin ninguna.
Para lo del www, lo más limpio es redirigir siempre a una sola versión del dominio. Si de verdad necesitas compartir la sesión entre subdominios, pon el dominio con un punto delante:
SESSION_DOMAIN=.miapp.testSESSION_SAME_SITE viene a lax, y con ese valor el navegador no envía la cookie en un POST que llega desde otro dominio. Es justo lo que queremos para frenar los ataques CSRF, así que, si el 419 aparece en un formulario que otra web envía a la tuya, lo que hay que revisar es quién hace esa petición. Si es un servicio externo, la solución es excluir la ruta, como veremos más abajo, y no tocar SameSite.
Y si en producción ejecutas php artisan config:cache, ten en cuenta que Laravel deja de leer el .env, así que después de cambiar cualquiera de estas variables tienes que limpiar la caché de configuración o volver a generarla:
php artisan config:clear4. El driver de sesión o los permisos de storage
Aquí hay que separar 2 situaciones, porque no todos los problemas de sesión acaban en 419. Si Laravel no puede escribir la sesión, porque la carpeta storage/framework/sessions no tiene permisos de escritura con el driver file o porque falta la tabla sessions con el driver database, lo que obtienes es un error 500, con un Permission denied o un Table 'laravel.sessions' doesn't exist en el log.
El 419 aparece cuando la sesión se guarda, pero la siguiente petición no la encuentra. El caso más claro es SESSION_DRIVER=array, que no conserva nada entre peticiones porque está pensado para los tests, y con él todos los formularios devuelven 419. El otro es tener varios servidores detrás de un balanceador con el driver file, porque cada servidor guarda las sesiones en su propio disco y el POST puede llegar a uno que no tiene la sesión creada en el GET. En los 2 casos, la solución es un driver que compartan todas las peticiones, como database, que es el que trae un proyecto nuevo, o redis:
SESSION_DRIVER=database5. Peticiones AJAX o fetch sin el token
Si el formulario funciona pero tus peticiones desde JavaScript devuelven 419, lo normal es que no estén enviando el token. Con fetch tienes que hacerlo tú: guarda el token en una etiqueta meta del layout y envíalo en la cabecera X-CSRF-TOKEN.
<meta name="csrf-token" content="{{ csrf_token() }}">const token = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch('/notas', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': token,
},
body: JSON.stringify({ texto: 'Primera nota' }),
});Cuando el token falta, como la petición pide JSON, Laravel responde con el estado 419 y un JSON con el mensaje CSRF token mismatch. en lugar de la página de error, y eso es lo que verás en la respuesta de la petición.
Si usas axios, no necesitas nada de esto: lee la cookie XSRF-TOKEN que Laravel añade a cada respuesta y la envía en la cabecera X-XSRF-TOKEN en las peticiones al mismo origen. A día de hoy el esqueleto de Laravel 13 ya no incluye axios, pero si lo añades a tu proyecto, funciona sin configurar nada. Como con los formularios, en Laravel 13 este 419 lo verás sobre todo por HTTP o en peticiones a otro subdominio.
6. Una SPA con Sanctum en otro subdominio
Cuando el frontend vive en un subdominio y la API en otro, por ejemplo spa.miapp.test y api.miapp.test, para el navegador las peticiones son same-site y no same-origin, así que Laravel 13 vuelve a depender del token. La autenticación de SPA de Sanctum necesita varias piezas, y si falta alguna, el resultado es un 419 o un 401. Lo primero es instalar Sanctum, si aún no lo tienes, y activar las peticiones con estado en bootstrap/app.php:
php artisan install:api->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})Después, en el .env, le decimos a Sanctum desde qué dominio llega la SPA y compartimos la cookie de sesión entre los subdominios. Si en local la SPA usa un puerto, tienes que incluirlo en SANCTUM_STATEFUL_DOMAINS, por ejemplo spa.miapp.test:5173:
SANCTUM_STATEFUL_DOMAINS=spa.miapp.test
SESSION_DOMAIN=.miapp.testLaravel no publica la configuración de CORS por defecto, así que la publicamos y, en config/cors.php, activamos el envío de credenciales:
php artisan config:publish cors'supports_credentials' => true,Y en la SPA, antes de hacer login o cualquier otra petición que modifique datos, pedimos la cookie con el token a /sanctum/csrf-cookie:
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
await axios.get('https://api.miapp.test/sanctum/csrf-cookie');
await axios.post('https://api.miapp.test/api/notas', { texto: 'Desde la SPA' });Fíjate en withXSRFToken. Sin esa opción, axios solo envía la cabecera X-XSRF-TOKEN en las peticiones al mismo origen, y desde spa.miapp.test hacia api.miapp.test no la envía, así que el POST devuelve 419 aunque hayas pedido antes /sanctum/csrf-cookie. Lo mismo ocurre si dejas SESSION_DOMAIN a null: la cookie XSRF-TOKEN se queda en api.miapp.test y la SPA no puede leerla. Si la SPA está en un dominio distinto, esta autenticación no te sirve, porque Sanctum exige que la SPA y la API cuelguen del mismo dominio, por ejemplo miapp.com y api.miapp.com. Tienes más detalles en el artículo ¿Qué es Laravel Sanctum? SPA y API Tokens.
Laravel 13 añade otra opción, $middleware->preventRequestForgery(allowSameSite: true), que acepta sin token las peticiones que llegan desde tus propios subdominios. Funciona, pero solo tiene sentido si controlas todos los subdominios del dominio, porque cualquiera de ellos podrá enviar peticiones a tu aplicación.
7. Una caché de página sirve el token de otro usuario
Esta cuesta más de detectar, porque solo falla a algunos usuarios y en algunas páginas. Si una caché de página completa, como la de un CDN o la de un paquete que cachea respuestas, guarda el HTML de una página con formulario, guarda también el token de la sesión del primer visitante. A partir de ahí, todos los demás reciben ese token, que no coincide con su sesión, y el envío devuelve 419. En el log la reconoces porque llegan el token y la cookie de sesión, pero no coinciden.
Si miras las cabeceras de una página de Laravel, por ejemplo con curl -I, verás Cache-Control: no-cache, private, así que esto ocurre cuando la caché se configura para guardar el HTML de todas formas. La solución es no cachear las páginas con formularios, y si alguna tiene que estar en caché sí o sí, como una landing con un formulario de contacto, puedes pedir el token al cargarla con una ruta que no se cachea, en routes/web.php:
Route::get('/csrf-token', function () {
return response()->json(['token' => csrf_token()]);
});Y en la página cacheada, un script que sustituye el token del HTML por el de la sesión del visitante:
fetch('/csrf-token')
.then((response) => response.json())
.then(({ token }) => {
document.querySelectorAll('input[name="_token"]').forEach((input) => {
input.value = token;
});
});¿Cuándo excluir una ruta de la protección CSRF y cuándo no?
Excluir una ruta tiene sentido cuando quien hace la petición no es el navegador de uno de tus usuarios y, por tanto, no tiene ni sesión ni token. El caso típico es un webhook: Stripe, una pasarela de pago o GitHub envían un POST desde sus servidores, y la autenticidad de la petición se comprueba con la firma que incluyen, que tienes que validar en tu ruta.
La documentación de CSRF de Laravel 13 recomienda sacar esas rutas del grupo de middleware web, y si tu ruta no usa la sesión, lo más limpio es definirla en routes/api.php, cuyas rutas no aplican la protección CSRF a peticiones de servidor a servidor. Si tiene que estar en routes/web.php, en Laravel 13 la excluyes en bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'webhooks/*',
]);
})En Laravel 11 y 12, el mismo array va en $middleware->validateCsrfTokens(except: [...]), que en Laravel 13 sigue funcionando como alias obsoleto. En Laravel 10, la ruta se añade a la propiedad $except del middleware App\Http\Middleware\VerifyCsrfToken:
protected $except = [
'webhooks/*',
];Lo que no conviene hacer nunca es excluir una ruta para que desaparezca el 419 de tus propios formularios o de tu JavaScript. El error desaparece, sí, pero esa ruta queda abierta a que cualquier web envíe peticiones en nombre de tus usuarios, y la causa de fondo, que casi siempre es una de las 7 anteriores, sigue ahí. Si quieres repasar cómo funcionan los grupos de middleware, lo tienes en Middlewares en Laravel: Guía Completa.
¿Cómo personalizar la página del error 419?
Si prefieres mantener la página de error en lugar de devolver al usuario al formulario, al menos que se entienda. Laravel busca una vista con el código del error en resources/views/errors, así que basta con crear resources/views/errors/419.blade.php, y la forma más rápida es aprovechar el diseño mínimo que ya trae Laravel:
@extends('errors::minimal')
@section('title', 'Página caducada')
@section('code', '419')
@section('message', 'La página ha caducado. Vuelve atrás, recarga y envía el formulario de nuevo.')Si quieres cambiar el diseño de todas las páginas de error, puedes publicar las plantillas de Laravel en esa misma carpeta y editarlas a tu gusto:
php artisan vendor:publish --tag=laravel-errorsTen en cuenta que, si usas la redirección del apartado de la sesión caducada, esta vista ya no se mostrará al enviar un formulario, porque el callback responde antes.
Con esto ya tienes las 7 causas del 419 y cómo reconocer cada una en la petición o en el log. Cuando te vuelva a aparecer, empieza por comprobar si ha llegado el token y si ha llegado la cookie de sesión, porque saber cuál de los 2 falta te lleva directamente a la causa. Todas las opciones de la protección CSRF las tienes en la documentación de CSRF de Laravel 13, y las de la autenticación de SPA, en la de Sanctum.
Espero que te haya resultado útil. Y si quieres entender mejor cómo funciona la protección CSRF y cómo personalizarla, no te pierdas el artículo Protección CSRF en Laravel 11. Si estás empezando con Laravel, tienes también el Curso de Laravel 12.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción