Back to Blog
·17 min read

Laravel Sanctum + Vue SPA: Authentication That Works

LaravelVue.jsPHPAuthentication

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

  1. Vue app lives at app.example.com, API at api.example.com (or same domain)
  2. User logs in via /login — Laravel sets an encrypted session cookie
  3. Vue sends requests with credentials: 'include' — cookie goes automatically
  4. Sanctum validates the session and authenticates the API request
  5. CSRF protection via X-XSRF-TOKEN header

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_DOMAIN correctly for subdomain sharing
  • Use SameSite=lax or strict in session config
  • Always call /sanctum/csrf-cookie before 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.