Laravel Sanctum React SPA Authentication Guide
Laravel Sanctum React SPA authentication with cookies: CORS, session domain, CSRF and Axios settings that work, plus fixes for 419, 401 and CORS errors.
Laravel Sanctum with a React SPA gives you secure, cookie-based authentication without storing tokens in localStorage, where any XSS bug can steal them. The browser holds an HttpOnly session cookie that JavaScript cannot read, and Laravel protects every state-changing request with CSRF.
It is simple once the configuration lines up, and maddening until it does. I have lost afternoons to a single missing port in SANCTUM_STATEFUL_DOMAINS. In this guide I will show the full setup I use on Laravel 11 with a React 18 front-end built with Vite: backend configuration, CORS, the Axios client, a login flow, a React auth context with protected routes, and a checklist for the 419, 401 and CORS errors you will almost certainly see.
Prerequisites
- Laravel 11 on PHP 8.3 serving your API
- A React 18 app created with Vite
- Axios 1.6 or newer (needed for the withXSRFToken option)
- Both apps on the same top-level domain, or both on localhost in development
- A users table and the default session configuration
SPA cookies vs API tokens: which Sanctum mode to use
Sanctum has two modes. API tokens are long-lived personal access tokens sent in an Authorization header; they suit mobile apps, CLI tools and third-party clients. SPA authentication uses Laravel's normal session cookie, and it is what you want when your own React app is the client.
The cookie approach is safer for browsers because the session cookie is HttpOnly and SameSite=Lax, so it cannot be read by injected scripts and is not sent on most cross-site requests. You also get session expiry, logout and remember-me behaviour for free.
Step 1: Use the same top-level domain
Cookie authentication requires the SPA and API to share a top-level domain, for example app.example.com and api.example.com. In local development use localhost for both; different ports are fine. Do not mix localhost and 127.0.0.1, because the browser treats them as different sites and the cookies will not be shared.
Step 2: Install Sanctum in Laravel 11
Laravel 11 ships without an api routes file by default. The install:api command creates routes/api.php, installs Sanctum and publishes its migration. Then enable stateful API authentication so requests from your SPA domain get session and CSRF middleware.
php artisan install:api
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})Step 3: Configure Laravel Sanctum, CORS and the session
Tell Sanctum which domains are stateful, allow credentials in CORS, and set the session domain. SANCTUM_STATEFUL_DOMAINS must include the port in development. SESSION_DOMAIN with a leading dot shares the cookie across subdomains in production.
# .env (local)
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173
SESSION_DOMAIN=localhost
SESSION_DRIVER=database
# .env (production)
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=truePublish the CORS config with php artisan config:publish cors, then allow your front-end origin with credentials. A wildcard origin is not allowed when credentials are enabled; browsers reject it.
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'allowed_headers' => ['*'],
'supports_credentials' => true,
];Step 4: Login and logout routes
You can use Laravel Breeze's API stack or Fortify, but the routes are short enough to write yourself. Regenerating the session on login prevents session fixation, and invalidating it on logout clears the old CSRF token.
// routes/web.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
Route::post('/login', function (Request $request) {
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => 'These credentials do not match our records.',
]);
}
$request->session()->regenerate();
return response()->noContent();
})->middleware('throttle:6,1');
Route::post('/logout', function (Request $request) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
});
// routes/api.php
Route::get('/user', fn (Request $request) => $request->user())
->middleware('auth:sanctum');Step 5: Axios setup in React
withCredentials tells the browser to send and accept cookies on cross-origin requests. withXSRFToken makes Axios read the XSRF-TOKEN cookie and send it back as the X-XSRF-TOKEN header even when the API is on a different origin. Without it, Axios 1.6+ only does this for same-origin requests, which is the cause of many mysterious 419 errors.
// src/lib/api.js
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL, // e.g. http://localhost:8000
withCredentials: true,
withXSRFToken: true,
headers: { Accept: 'application/json' },
})Step 6: The Sanctum login flow in React
First request the CSRF cookie, then post the credentials. After that every request is authenticated by the session cookie. The Accept: application/json header matters: without it, a validation failure returns a redirect instead of a 422 JSON response.
// src/lib/auth.js
import { api } from './api'
export async function login(email, password, remember = false) {
await api.get('/sanctum/csrf-cookie')
await api.post('/login', { email, password, remember })
const { data } = await api.get('/api/user')
return data
}
export async function logout() {
await api.post('/logout')
}
export async function fetchUser() {
try {
const { data } = await api.get('/api/user')
return data
} catch (err) {
if (err.response?.status === 401) return null
throw err
}
}Step 7: Auth context and protected routes
Keep the current user in a React context and check the session once when the app loads. This example uses React Router 6.
// src/AuthProvider.jsx
import { createContext, useContext, useEffect, useState } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import * as auth from './lib/auth'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
auth.fetchUser().then(setUser).finally(() => setLoading(false))
}, [])
const value = {
user,
loading,
login: async (email, password) => setUser(await auth.login(email, password)),
logout: async () => { await auth.logout(); setUser(null) },
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
export function RequireAuth() {
const { user, loading } = useAuth()
if (loading) return <p>Loading...</p>
return user ? <Outlet /> : <Navigate to="/login" replace />
}Add an Axios response interceptor if you want to handle expired sessions globally: on a 401, clear the user and redirect to the login page. On a 419, request a fresh CSRF cookie and retry the request once.
Common Laravel Sanctum React errors and fixes
- 419 CSRF token mismatch: the XSRF-TOKEN cookie was not sent back. Check withCredentials and withXSRFToken, call /sanctum/csrf-cookie first, and make sure the SPA origin is in SANCTUM_STATEFUL_DOMAINS including the port.
- CORS error mentioning credentials: supports_credentials is false, or allowed_origins is a wildcard. List the exact origin.
- 401 on /api/user right after a successful login: SESSION_DOMAIN does not match the domain the browser is on, or statefulApi is not enabled so the request is treated as a token request.
- Cookies never appear in the browser: you mixed localhost and 127.0.0.1, or SESSION_SECURE_COOKIE is true on plain HTTP.
- Everything works locally but fails in production: the API and SPA are on different top-level domains, or a proxy strips Set-Cookie headers. Run php artisan config:clear after changing .env.
Security best practices
- Never store auth tokens in localStorage for a browser SPA; let the HttpOnly cookie do the work.
- Serve both apps over HTTPS in production and set SESSION_SECURE_COOKIE=true.
- Throttle the login route to slow down credential stuffing.
- Regenerate the session on login and invalidate it on logout.
- Keep a strict Content Security Policy, because CSRF protection does not help if an attacker can run scripts on your own origin.
Conclusion
Laravel Sanctum and React work well together once four things agree: a shared top-level domain, stateful domains that include the exact origin, CORS with credentials, and an Axios client that sends cookies and the XSRF header. Set those up once, wrap the user in a context, and you have authentication that is both simpler and safer than hand-rolled JWTs in localStorage.
Frequently asked questions
Should I use Sanctum SPA authentication or API tokens with React?
For your own React app on the same top-level domain, use SPA cookie authentication because the session cookie is HttpOnly and protected by CSRF. Use API tokens for mobile apps, third-party clients or front-ends on a completely different domain.
Why do I get a 419 CSRF token mismatch with Laravel Sanctum?
The X-XSRF-TOKEN header is missing or does not match the session. Call /sanctum/csrf-cookie before logging in, enable withCredentials and withXSRFToken in Axios, and confirm your SPA origin is listed in SANCTUM_STATEFUL_DOMAINS.
Can Laravel Sanctum work with a React app on a different domain?
Cookie-based SPA authentication needs both apps on the same top-level domain, such as app.example.com and api.example.com. If the domains are completely different, use Sanctum API tokens instead.
Do I need Laravel Breeze to use Sanctum with React?
No. Breeze's API stack gives you ready-made controllers, but login and logout are only a few lines. Writing them yourself makes the session handling easier to understand and debug.