Categoría / Laravel
Autenticación de una SPA Vue con Laravel Sanctum: cookies, CSRF y CORS
Configura una SPA Vue separada con sesiones de Sanctum, protección CSRF, CORS con credenciales y un flujo seguro de login y logout.
Para autenticar una SPA Vue propia, Laravel Sanctum utiliza la sesión web: el navegador conserva una cookie de sesión HttpOnly y envía un token CSRF en las peticiones que modifican estado. No se crea un token Bearer ni se guarda una credencial en localStorage.
Este flujo encaja cuando el frontend y la API pertenecen al mismo producto y comparten dominio raíz, por ejemplo app.example.com y api.example.com. Si otro servicio necesita validar un token autocontenido, el problema es distinto; consulta JWT en Laravel. Si no necesitas separar frontend y backend, una SPA con Laravel, Vue e Inertia evita toda la configuración entre orígenes.
Cómo funciona la autenticación stateful
El flujo completo tiene cuatro pasos:
- Vue solicita
GET /sanctum/csrf-cookiecon credenciales. - Laravel responde con la cookie
XSRF-TOKENy una cookie de sesión. - Vue envía
POST /login; Axios copia el valor decodificado deXSRF-TOKENa la cabeceraX-XSRF-TOKEN. - Laravel regenera la sesión y las peticiones posteriores a rutas
auth:sanctumquedan autenticadas por la cookie.
Sanctum reconoce primero la petición como procedente de un frontend stateful. Después intenta autenticarla con la sesión. Si no hay cookie, también puede comprobar un token de API, pero una SPA propia no necesita emitirlo.
La SPA y la API pueden usar orígenes distintos, pero deben compartir el mismo dominio de nivel superior. app.example.com y api.example.com cumplen el requisito; frontend.example y backend.example.net no.
Instalar Sanctum y activar el middleware
Si el proyecto aún no tiene Sanctum ni routes/api.php, ejecuta el instalador oficial y las migraciones:
php artisan install:api
php artisan migrate
La tabla de tokens personales que instala Sanctum no participa en la autenticación por cookies, aunque puede convivir con ella para clientes de terceros.
Activa el procesamiento stateful del grupo API en bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
// ...
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
->create();
Sin statefulApi(), una petición a routes/api.php no recuperará la sesión del navegador aunque la cookie llegue al servidor.
Dominios, puertos y cookies
En desarrollo, usa el mismo nombre de host en ambos proyectos. Este ejemplo ejecuta Vue en http://localhost:5173 y Laravel en http://localhost:8000:
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
No alternes localhost y 127.0.0.1: para el navegador son hosts distintos. En SANCTUM_STATEFUL_DOMAINS se escribe el host que origina la petición, sin http://, y se incluye el puerto cuando existe. SESSION_DOMAIN tampoco lleva esquema ni puerto; null mantiene una cookie limitada al host, suficiente cuando ambos servicios usan localhost.
Con subdominios en producción, comparte la cookie con el dominio raíz y exige HTTPS:
APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
El punto inicial de .example.com permite que la cookie cubra sus subdominios. SameSite=Lax funciona en este caso porque los dos orígenes siguen perteneciendo al mismo sitio. Una arquitectura realmente cross-site queda fuera del modelo de SPA stateful recomendado por Sanctum.
Después de modificar el entorno, elimina la configuración cacheada:
php artisan config:clear
Permitir credenciales con CORS
Laravel responde automáticamente a las peticiones preflight mediante HandleCors. Publica su archivo de configuración solo si necesitas personalizarlo:
php artisan config:publish cors
En config/cors.php, incluye todos los endpoints que Vue llamará desde el otro origen:
<?php
return [
'paths' => [
'api/*',
'sanctum/csrf-cookie',
'login',
'logout',
],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 600,
'supports_credentials' => true,
];
El origen permitido sí lleva esquema y, en desarrollo, puerto. Con supports_credentials activado no uses * como origen: el navegador exige un valor concreto que coincida con su cabecera Origin. Para profundizar en preflight, cabeceras y proxies, revisa cómo configurar CORS en Laravel.
Crear login, logout y una ruta protegida
El login debe utilizar la autenticación de sesión normal de Laravel y regenerar el identificador de sesión. Crea el controlador:
php artisan make:controller Auth/AuthenticatedSessionController
En app/Http/Controllers/Auth/AuthenticatedSessionController.php:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AuthenticatedSessionController extends Controller
{
public function store(Request $request): Response
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials)) {
throw ValidationException::withMessages([
'email' => ['Las credenciales no son válidas.'],
]);
}
$request->session()->regenerate();
return response()->noContent();
}
public function destroy(Request $request): Response
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
}
La regeneración tras el login evita la fijación de sesión. Al cerrar sesión, invalida la sesión y renueva el token CSRF; borrar solo el estado de Vue no cierra la sesión en Laravel.
Declara login y logout en routes/web.php, donde se aplican sesión y protección CSRF:
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthenticatedSessionController::class, 'store'])
->middleware('throttle:5,1');
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
->middleware('auth');
Protege los datos de la SPA en routes/api.php:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
auth:sanctum identifica al usuario; las Policies siguen decidiendo qué recursos puede usar. La guía para crear una API REST segura con Laravel cubre validación, autorización y recursos JSON sin repetir el flujo de sesión.
Comprueba que las rutas existen:
php artisan route:list --path=sanctum
php artisan route:list --path=login
php artisan route:list --path=api/user
Configurar Axios en Vue
Centraliza la configuración para no olvidar credenciales en una petición. Por ejemplo, en src/lib/api.ts:
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
withXSRFToken: true,
headers: {
Accept: 'application/json',
},
});
Y en el entorno de Vue:
VITE_API_URL=http://localhost:8000
El login debe inicializar CSRF antes de enviar credenciales:
import { api } from '@/lib/api';
export async function login(email: string, password: string) {
await api.get('/sanctum/csrf-cookie');
await api.post('/login', { email, password });
const response = await api.get('/api/user');
return response.data;
}
export async function logout() {
await api.post('/logout');
}
withCredentials permite recibir y enviar cookies entre los dos orígenes. withXSRFToken hace que Axios decodifique XSRF-TOKEN y envíe X-XSRF-TOKEN. No intentes leer la cookie de sesión: debe permanecer HttpOnly.
Diagnosticar 401, 419 y errores CORS
| Síntoma | Qué significa normalmente | Qué comprobar |
|---|---|---|
401 Unauthenticated en /api/user | Laravel no recibió una sesión autenticada | Cookie de sesión, statefulApi(), dominio stateful, Origin o Referer y Accept: application/json |
419 Page Expired en login o logout | Falta el token CSRF o no coincide con la sesión | Petición previa a /sanctum/csrf-cookie, cookies, X-XSRF-TOKEN y dominios coherentes |
| Error CORS visible solo en el navegador | El navegador bloqueó la respuesta | Origen exacto, rutas CORS, supports_credentials, preflight OPTIONS y configuración del proxy |
422 Unprocessable Content en login | Credenciales o campos no válidos | Cuerpo JSON y mensajes de validación; no lo trates como un fallo CORS |
Respuesta HTML o redirección 302 | Laravel no negoció una respuesta JSON | Cabecera Accept: application/json y URL correcta |
Inspecciona primero la pestaña Network. La respuesta de /sanctum/csrf-cookie debe incluir XSRF-TOKEN; el login debe enviar X-XSRF-TOKEN y recibir la cookie de sesión; /api/user debe reenviar esa cookie. Si cambiaste host, puerto o dominio de cookie, borra las cookies antiguas antes de repetir la prueba.
No soluciones un 419 excluyendo login o logout de CSRF. Tampoco añadas manualmente cabeceras CORS desde el controlador: el middleware global debe responder tanto al preflight como a la petición real.
Pruebas mínimas
Crea un test de integración y ejecútalo con el runner de Laravel:
php artisan make:test SpaAuthenticationTest
php artisan test --testsuite=Feature --stop-on-failure
El test debe demostrar, como mínimo, que:
- un visitante recibe
401al consultar/api/user; - unas credenciales válidas crean una sesión y permiten consultar
/api/user; - unas credenciales incorrectas no autentican y devuelven el error de validación esperado;
POST /logoutinvalida la sesión y la siguiente petición protegida vuelve a responder401;- dos usuarios no pueden acceder a recursos ajenos aunque ambos estén autenticados.
Laravel desactiva el middleware CSRF durante los tests automatizados. Por eso necesitas además una prueba real en navegador o end-to-end que recorra csrf-cookie → login → ruta protegida → logout, revise las cookies y cubra un origen rechazado. Ejecuta también el flujo tras expirar o eliminar la sesión para comprobar que la interfaz devuelve al usuario al login ante 401 o 419.
Antes de producción, verifica HTTPS, SESSION_SECURE_COOKIE=true, un origen CORS explícito, un almacén de sesiones compartido si hay varias instancias y límites de intentos para el login. Nunca registres contraseñas, cookies de sesión ni valores completos de XSRF-TOKEN.