Laravel 12 API Authentication Using Sanctum for SPAs
In this tutorial we will see how to authenticate users using Laravel Sanctum for a Vue.js SPA (single-page application). For that we’ll be building a simple app with login and registration features and a simple page for authenticated users only, which will show info about the logged-in user.
We will be using Laravel’s built-in cookie based session authentication feature to authenticate our SPA. See the docs for more info on that.
If you want to use this method, make sure that your ‘SPA and API share the same top-level domain.’
And in this tutorial we are going to create two separate projects. One is for Laravel, and the other is for Vue.js.
#
Setting up the Laravel api
##
Installing Laravel Fortify
Laravel Fortify provides a backend authentication implementation for Laravel that’s independent of any frontend. Click Here to go to its documentation page.
We will use Fortify for the register and login features. Let’s get started.
composer require laravel/fortify
php artisan fortify:install
php artisan migrate
###
Disabling Views
config/fortify.php
// ...
'views' => false,
// ...
###
Setting a prefix
We will need this later on for cors to allow the fortify paths.
config/fortify.php
// ...
'prefix' => 'auth',
// ...
I’m only going to be implementing the login and register features, so I’m going to comment out all the other Fortify features:
config/fortify.php
// ...
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
/* Features::resetPasswords(), */
// Features::emailVerification(),
/* Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]), */
],
// ...
##
Installing the api features
After creating a new laravel 12 project with no starter kit and installing fortify, let’s start installing the api features:
php artisan install:api
After that, let’s add the [Laravel\Sanctum\HasApiTokens] trait to the User model:
app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
// ...
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
// ...
Next, we should enable the statefulApi middleware:
bootstrap/app.php
// ...
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
// ...
Publish the CORS config file.
php artisan config:publish cors
Add the foritify paths, the allowed_origins, and set supports_credentials to true.
config/cors.php
// ...
'paths' => [
'api/*',
'sanctum/csrf-cookie',
// fortify prefix
'auth/*',
],
// ...
'allowed_origins' => [env('FRONTEND_URL', '*')],
// ...
'supports_credentials' => true,
// ...
In your .env file, set the SANCTUM_STATEFUL_DOMAINS and SESSION_DOMAIN.
.env
// ...
# the url of the client app
FRONTEND_URL=http://localhost:5173
# the top level domain name
SESSION_DOMAIN=localhost
# the domain name of the client application
SANCTUM_STATEFUL_DOMAINS=localhost:5173
// ...
#
Setting up the Vue js SPA
Now let’s start by creating a new Vue.js project with Pinia and the Vue Router:
npm create vue@latest
##
Install and configure axios
npm i axios
src/utils/api.js
import axios from "axios";
const baseURL = "http://localhost:8000";
const api = axios.create({
baseURL,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
withCredentials: true,
withXSRFToken: true,
});
export default api;
##
Create the Pinia auth store
src/stores/auth.js
import { defineStore } from "pinia";
import api from "@/utils/api";
import { ref, computed } from "vue";
import router from "../router";
export const useAuthStore = defineStore("auth", () => {
const user = ref(null);
const loading = ref(false);
const errors = ref(null);
const isAuthenticated = computed(() => !!user.value);
const csrf = async () => {
await api.get("/sanctum/csrf-cookie");
};
const fetchUser = async () => {
loading.value = true;
errors.value = null;
try {
const res = await api.get("/api/user");
user.value = res.data;
return user.value;
} catch (err) {
user.value = null;
if (err.status !== 401) {
console.error(err);
}
} finally {
loading.value = false;
}
return null;
};
const login = async (payload) => {
loading.value = true;
errors.value = null;
try {
await csrf();
await api.post("/auth/login", payload);
await fetchUser();
router.push({ name: "dashboard" });
} catch (err) {
if (err.response?.data) {
errors.value = err.response.data;
} else {
console.error(err);
}
} finally {
loading.value = false;
}
};
const register = async (payload) => {
loading.value = true;
errors.value = null;
try {
await csrf();
await api.post("/auth/register", payload);
await fetchUser();
router.push({ name: "dashboard" });
} catch (err) {
if (err.response?.data) {
errors.value = err.response.data;
} else {
console.error(err);
}
} finally {
loading.value = false;
}
};
const logout = async () => {
loading.value = true;
errors.value = null;
try {
await api.post("/auth/logout");
handleLogout();
} catch (err) {
if (
err.response &&
(err.response.status === 401 || err.response.status === 419)
) {
handleLogout();
}
console.error(err);
} finally {
loading.value = false;
}
};
const handleLogout = () => {
user.value = null;
router.push({ name: "login" });
};
return {
user,
loading,
errors,
isAuthenticated,
csrf,
login,
register,
fetchUser,
logout,
};
});
##
Add the views
src/views/Login.vue
<template>
<div class="auth-page">
<h1>Login</h1>
<form @submit.prevent="onSubmit">
<div style="color: red;" v-if="errors?.message">
{{ errors?.message }}
</div>
<div>
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" required />
</div>
<div>
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
/>
</div>
<button :disabled="loading" type="submit">Login</button>
</form>
<p>
Don't have an account?
<router-link to="/register">Register</router-link>
</p>
</div>
</template>
<script setup>
import { reactive, computed } from "vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const form = reactive({
email: "",
password: "",
});
const loading = computed(() => auth.loading);
const errors = computed(() => auth.errors);
const onSubmit = async () => {
await auth.login({ email: form.email, password: form.password });
};
</script>
src/views/Register.vue
<template>
<div class="auth-page">
<h1>Register</h1>
<form @submit.prevent="onSubmit">
<div style="color: red;" v-if="errors?.message">
{{ errors?.message }}
</div>
<div>
<label for="name">Name</label>
<input id="name" v-model="form.name" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" required />
</div>
<div>
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
/>
</div>
<div>
<label for="password_confirmation">Confirm password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
/>
</div>
<button :disabled="loading" type="submit">Register</button>
</form>
<p>
Already have an account?
<router-link to="/login">Login</router-link>
</p>
</div>
</template>
<script setup>
import { reactive, computed } from "vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const form = reactive({
name: "",
email: "",
password: "",
password_confirmation: "",
});
const loading = computed(() => auth.loading);
const errors = computed(() => auth.errors);
const onSubmit = async () => {
await auth.register(form);
};
</script>
src/views/Dashboard.vue
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<div v-if="user">
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
<button @click="logout" :disabled="loading">Logout</button>
</div>
<div v-else>
<p>No user data.</p>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const user = computed(() => auth.user);
const loading = computed(() => auth.loading);
const logout = async () => {
await auth.logout();
};
</script>
##
Configure the router
router/index.js
import { createRouter, createWebHistory } from "vue-router";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import Dashboard from "../views/Dashboard.vue";
import { useAuthStore } from "../stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
redirect: { name: "dashboard" },
},
{
path: "/login",
name: "login",
component: Login,
meta: { guest: true },
},
{
path: "/register",
name: "register",
component: Register,
meta: { guest: true },
},
{
path: "/dashboard",
name: "dashboard",
component: Dashboard,
meta: { requiresAuth: true },
},
],
});
router.beforeEach(async (to, from, next) => {
const auth = useAuthStore();
if (auth.user === null && to.meta.requiresAuth) {
// try to fetch; if it fails, user remains null
try {
await auth.fetchUser();
} catch (e) {
// ignore
}
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return next({ name: "login" });
}
if (to.meta.guest && auth.isAuthenticated) {
return next({ name: "dashboard" });
}
return next();
});
export default router;
Now you should have a Vue.js app that connects with you laravel api via sanctum, with simple auth functionality.