Laravel Sanctum + Fortify SPA Authentication: Fixing API Route Redirects
Fix redirect issues when using Laravel Sanctum with Fortify for SPA authentication. Complete setup guide for first-party SPA and API authentication.
A common issue when setting up Laravel Sanctum with Fortify for SPA authentication is that API routes unexpectedly redirect to the login page instead of returning JSON 401 responses. This happens because Laravel's default authentication middleware behavior is geared toward session-based web apps, not stateless API consumers. Here is exactly why it happens and how to fix it.
The Problem
You have a Laravel 13 application with Sanctum and Fortify installed. Your SPA lives on the same domain as your Laravel backend. You configured Sanctum for SPA authentication following the docs. But when your SPA makes an API call without a valid token, instead of getting a {"message": "Unauthenticated"} JSON response, the server returns an HTML redirect to /login.
// Expected:
{
"message": "Unauthenticated"
}
// Actual:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url=/login" />
...
The root cause: Laravel's auth middleware redirects unauthenticated users based on the redirectTo() method in App\Exceptions\Handler or App\Http\Middleware\Authenticate. It checks expectsJson() on the request, but under certain configurations, that check fails.
Why It Happens
There are three common causes:
1. Incorrect Middleware on API Routes
Using auth middleware instead of auth:sanctum on your API routes:
<?php
// routes/api.php — WRONG
Route::middleware('auth')->group(function () {
// These routes redirect to login instead of returning 401
});
// routes/api.php — CORRECT
Route::middleware('auth:sanctum')->group(function () {
// Sanctum guard returns proper JSON 401
}); 2. SPA Not Sending Accept Header
Sanctum's SPA authentication expects the Accept: application/json header. Without it, Laravel assumes it is a browser request and redirects:
// Frontend fetch — missing Accept header
fetch('/api/user');
// Server sees non-XHR, no Accept header → redirect
// Correct way
fetch('/api/user', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'include', // Required for Sanctum SPA auth
}); 3. Custom Authenticate Middleware Override
If you customized app/Http/Middleware/Authenticate.php, the redirectTo() method might not check expectsJson():
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
protected function redirectTo($request)
{
// Missing the expectsJson check!
return route('login');
}
}
// Fixed version:
protected function redirectTo($request)
{
if (!$request->expectsJson()) {
return route('login');
}
} Complete Setup Checklist
Here is a verified working setup for Sanctum SPA authentication with Fortify on Laravel 13:
Step 1: Install and Configure Sanctum
composer require laravel/sanctum
php artisan install:api
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" <?php
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Laravel\Sanctum\Http\Middleware\EncryptCookies::class,
'validate_csrf_token' => Laravel\Sanctum\Http\Middleware\ValidateCsrfToken::class,
], Step 2: Configure CORS
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'supports_credentials' => true,
]; Step 3: Configure Fortify
// config/fortify.php
'views' => false, // Disable Fortify's views since you have an SPA
// Make sure Fortify's routes use the correct guard
'guard' => 'web', // Keep web guard for SPA authentication via cookies Step 4: API Routes Setup
<?php
// routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum'); Step 5: Bootstrap the SPA Auth
// Frontend — Vue/React/vanilla JS
async function login(email, password) {
// Step 1: Get CSRF cookie
await fetch('/sanctum/csrf-cookie', {
method: 'GET',
credentials: 'include',
});
// Step 2: Login via Fortify
const response = await fetch('/login', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ email, password }),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
// Now authenticated — subsequent API calls work with cookie
}
async function fetchUser() {
const response = await fetch('/api/user', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'include',
});
if (response.status === 401) {
// Not authenticated — redirect to login
return null;
}
return response.json();
} Quick Debugging Checklist
If your API routes are still redirecting, run through this checklist:
- Run
php artisan route:listand verify your API routes useauth:sanctum, notauth - Check the browser's Network tab — are your API requests sending the
Accept: application/jsonheader? - Are your requests including
credentials: 'include'? - Is
SESSION_DRIVERset tocookiein your.env? - Is
SANCTUM_STATEFUL_DOMAINScorrectly set to include your frontend domain? - Verify your SPA and backend are on the same top-level domain (Sanctum requires this for SPA auth)
Alternative: Use API Tokens Instead
If you cannot use the same domain for your SPA and API, switch to Sanctum's API token approach instead:
<?php
// Generate a token
$token = $user->createToken('spa-token')->plainTextToken;
// Return it to the frontend
return response()->json(['token' => $token]);
// Frontend sends it as a Bearer token
fetch('/api/user', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
}); This approach does not require cookies, CSRF protection, or same-domain constraints. The trade-off is that tokens stored in localStorage are more vulnerable to XSS attacks than HTTP-only cookies.
Conclusion
The API route redirect problem with Sanctum and Fortify almost always comes down to one of three things: using the wrong middleware guard, missing the Accept: application/json header, or a misconfigured Authenticate middleware. Fix those three and your SPA authentication will return proper JSON responses as expected.
Stefan
SEO engineer and Laravel developer. Building tools to help Laravel applications rank higher in search results.