Laravel Sanctum + Vue SPA: Authentication That Works
Token-based auth sounds simple until you deal with token storage, XSS vulnerabilities, and refresh logic. For first-party Vue SPAs on the same domain, Laravel Sanctum’s cookie-based SPA authentication is the better default.
This post walks through the full stack setup I use in production.
How Sanctum SPA auth works
- Vue app lives at
app.example.com, API atapi.example.com(or same domain) - User logs in via
/login— Laravel sets an encrypted session cookie - Vue sends requests with
credentials: 'include'— cookie goes automatically - Sanctum validates the session and authenticates the API request
- CSRF protection via
X-XSRF-TOKENheader
No tokens in localStorage. No manual Authorization headers for first-party apps.
Laravel backend setup
Install Sanctum:
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Configure stateful domains in config/sanctum.php:
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
))),
CORS in config/cors.php:
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'supports_credentials' => true,
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
Add Sanctum middleware to api group in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
})
Auth routes:
// routes/api.php
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
Route::get('/user', fn (Request $request) => new UserResource($request->user()))
->middleware('auth:sanctum');
Login controller:
class AuthController extends Controller
{
public function login(LoginRequest $request)
{
if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => ['These credentials do not match our records.'],
]);
}
$request->session()->regenerate();
return new UserResource(Auth::user());
}
public function logout(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
}
Vue frontend: API client setup
Configure $fetch or axios to send credentials and CSRF token:
// plugins/api.client.ts
export default defineNuxtPlugin(() => {
const api = $fetch.create({
baseURL: useRuntimeConfig().public.apiUrl,
credentials: 'include',
async onRequest({ options }) {
const token = useCookie('XSRF-TOKEN').value
if (token) {
options.headers = {
...options.headers,
'X-XSRF-TOKEN': decodeURIComponent(token),
}
}
},
})
return { provide: { api } }
})
Initialize CSRF cookie before login:
async function initCsrf() {
await $fetch('/sanctum/csrf-cookie', {
baseURL: useRuntimeConfig().public.apiUrl,
credentials: 'include',
})
}
Pinia auth store
// stores/auth.ts
interface User {
id: number
name: string
email: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const initialized = ref(false)
const isAuthenticated = computed(() => user.value !== null)
async function init() {
try {
user.value = await $fetch<User>('/api/user', { credentials: 'include' })
} catch {
user.value = null
} finally {
initialized.value = true
}
}
async function login(email: string, password: string, remember = false) {
await initCsrf()
user.value = await $fetch<User>('/api/login', {
method: 'POST',
body: { email, password, remember },
credentials: 'include',
})
}
async function logout() {
await $fetch('/api/logout', {
method: 'POST',
credentials: 'include',
})
user.value = null
}
return { user, initialized, isAuthenticated, init, login, logout }
})
Initialize on app mount:
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.initialized) {
await auth.init()
}
})
Route guards in Vue
// middleware/guest.ts
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (auth.isAuthenticated) {
return navigateTo('/dashboard')
}
})
// middleware/authenticated.ts
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
return navigateTo('/login')
}
})
Apply to pages:
<script setup lang="ts">
definePageMeta({
middleware: ['authenticated'],
})
</script>
Login page component
<script setup lang="ts">
definePageMeta({ middleware: ['guest'] })
const auth = useAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
loading.value = true
error.value = ''
try {
await auth.login(email.value, password.value)
navigateTo('/dashboard')
} catch {
error.value = 'Invalid email or password.'
} finally {
loading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleLogin">
<input v-model="email" type="email" required autocomplete="email" />
<input v-model="password" type="password" required autocomplete="current-password" />
<p v-if="error" class="text-red-500">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</template>
Handling 401 responses globally
Redirect to login when the session expires:
async onResponseError({ response }) {
if (response.status === 401) {
const auth = useAuthStore()
auth.user = null
navigateTo('/login')
}
}
Sanctum vs Passport vs tokens
| Approach | Best for |
|---|---|
| Sanctum SPA (cookies) | First-party Vue/React apps on same domain |
| Sanctum API tokens | Mobile apps, CLI tools, simple token auth |
| Passport | Full OAuth2 server, third-party integrations |
For a Laravel + Vue dashboard on the same project, Sanctum cookies win on security and simplicity.
Security checklist
- Set
SESSION_DOMAINcorrectly for subdomain sharing - Use
SameSite=laxorstrictin session config - Always call
/sanctum/csrf-cookiebefore state-changing requests - Never store session tokens in localStorage
- Enable HTTPS in production—cookies must be secure
- Regenerate session on login (
$request->session()->regenerate())
This pattern gives you seamless auth UX—users stay logged in across tabs, sessions expire naturally, and your Vue app never touches raw tokens.