Laravel 12 Vue 3 Token Based Authentication – Complete SPA Tutorial for Beginners

In this guide, you’ll learn how to implement laravel 12 vue 3 token based authentication step by step using Laravel Sanctum. We’ll build a complete Single Page Application (SPA) that includes secure user registration, login, logout, and route protection — all connected through API tokens between Laravel 12 and Vue 3.

To make the UI look better, we’ll use Bootstrap for styling, create a simple navigation menu, and highlight the active menu when switching pages — so you can clearly see how SPA routing works.
You’ll also learn how to create a fallback route to handle pages that are not found (404) and redirect users back to the correct route.

By the end of this tutorial, you’ll have a working laravel 12 vue 3 token based authentication system with a clean Bootstrap interface, dynamic routing, and secure API communication between the frontend and backend.

What You’ll Build

You’ll create:
✅ A Laravel 12 API backend with Sanctum
✅ A Vue 3 SPA frontend using Vue Router and Pinia
✅ A secure token-based authentication system with Sanctum tokens
✅ A Bootstrap-based UI with a navigation menu

By the end, you’ll be able to:

  • Register new users
  • Log in and receive authentication tokens
  • Access protected API routes securely
  • Stay logged in using local storage
  • Log out safely
  • Navigate between pages with active menu highlighting

This guide is perfect for beginners who want a clear, hands-on approach to implementing token-based authentication in Laravel 12 and Vue 3 SPA.

Step 1 : Create Laravel 12 Project and Configure Database

Start by creating a new Laravel 12 project using Composer and setting up your database connection in the .env file. This is the foundation for building laravel 12 vue 3 token based authentication later.

Run the following command in your terminal:

laravel new laravel-vue-token-auth

Edit your .env file:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_vue_token_auth
DB_USERNAME=root
DB_PASSWORD=

Then run the migrations:

php artisan migrate

Step 2 : Install and Configure Laravel Sanctum

Install Laravel Sanctum to handle secure token generation for API authentication. Configure Sanctum’s middleware and publish its config file to enable token-based access for your SPA.

Install it via Artisan command:

php artisan install:api

Open app/Models/User.php and ensure it uses the HasApiTokens trait:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
     use HasFactory, Notifiable, HasApiTokens;
}

Step 3 : Build Authentication Controller and Routes

Run the following command in your terminal:

php artisan make:controller Api/AuthController

Then edit app/Http/Controllers/Api/AuthController.php:

<?php
// Laravel 12 Vue 3 Token Based Authentication - http://laravelcenter.com
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
    // Register
    public function register(Request $request)
    {
        $rules = [
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|confirmed',
        ];

        $validator = Validator::make($request->all(), $rules);
        if ($validator->fails())
            return response()->json([
                'success' => false,
                'errors' => $validator->errors()
            ]);

        User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        return response()->json(['success' => true]);
    }

    // Login
    public function login(Request $request)
    {
        $rules = [
            'email' => 'required|email',
            'password' => 'required',
        ];

        $validator = Validator::make($request->all(), $rules);
        if ($validator->fails())
            return response()->json([
                'success' => false,
                'errors' => $validator->errors()
            ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            return response()->json(
                [
                    'success' => false,
                    'errors' => [
                        'email' => ['Incorrect email or password']
                    ]
                ]
            );
        }

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'success' => true,
            'token' => $token,
            'user' => $user->only('id', 'name', 'email'),
        ]);
    }

    // Get Authenticated User
    public function user(Request $request)
    {
        return response()->json($request->user()->only('id', 'name', 'email'));
    }

    // Logout
    public function logout(Request $request)
    {
        $request->user()->tokens()->delete();
        return response()->json(['message' => 'Logged out successfully']);
    }
}

Open routes/web.php and add:

<?php
// Laravel 12 Vue 3 Token Based Authentication - http://laravelcenter.com
use Illuminate\Support\Facades\Route;

Route::get('{any}', function () {
    return view('welcome');
})->where('any', '.*');

Open routes/api.php and add:

<?php
// Laravel 12 Vue 3 Token Based Authentication - http://laravelcenter.com
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;

Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', [AuthController::class, 'user']);
    Route::post('/logout', [AuthController::class, 'logout']);
});

Step 4 : Setup Vue 3 SPA and Configure vite.config.js

Initialize your Vue 3 SPA using Vite and configure it to communicate with your Laravel 12 API. This step forms the frontend part of the laravel 12 vue 3 token based authentication system.

Install the necessary packages:

npm install --save-dev @vitejs/plugin-vue
npm install vue@3 vue-router pinia axios bootstrap

Update content of vite.config.js file like below

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
// import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/js/app.js'],
            refresh: true,
        }),
        // tailwindcss(),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
    ],
});

Open resources/js/app.js:

import './bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'

createApp(App)
    .use(createPinia())
    .use(router)
    .mount('#app')

Step 5 : Setup Vue Router for SPA Navigation

Create resources/js/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes = [
    {
        path: "/login",
        component: () => import("@/pages/Login.vue"),
    },
    {
        path: "/register",
        component: () => import("@/pages/Register.vue"),
    },
    {
        path: "/",
        component: () => import("@/pages/Layout.vue"),
        meta: {
            requiresAuth: true,
        },
        children: [
            {
                path: "/",
                component: () => import("@/pages/Dashboard.vue"),
            },
            {
                path: "/data",
                component: () => import("@/pages/Data.vue")
            },
            {
                path: "/operation",
                component: () => import("@/pages/Operation.vue")
            },
            {
                path: "/report",
                component: () => import("@/pages/Report.vue")
            }
        ],
    },
    {
        path: "/:pathMatch(.*)*",
        component: () => import("@/pages/404.vue"),
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes,
})

router.beforeEach(async (to, from, next) => {
    const auth = useAuthStore()
    if (auth.token && !auth.user) {
        await auth.getUser();
    }

    if (to.meta.requiresAuth && !auth.user) {
        next('/login')
    } else if (auth.user && ['/login', '/register'].includes(to.path)) {
        next('/');
    }
    else {
        next()
    }
})

export default router

Step 6 : Create Pinia Store (auth.js)

Use Pinia for managing authentication state. The store will handle saving tokens to localStorage, checking login status, and protecting private routes.

Create resources/js/stores/auth.js:

import { defineStore } from 'pinia'
import axios from 'axios'
import router from '@/router'

export const useAuthStore = defineStore('auth', {
    state: () => ({
        user: null,
        token: localStorage.getItem('token') || null,
    }),

    actions: {
        async register(form) {
            return await axios.post('/api/register', form);
        },

        async login(form) {
            const response = await axios.post('/api/login', form)
            if (response.data.success) {
                this.token = response.data.token
                this.user = response.data.user
                localStorage.setItem('token', this.token)
            }
            return response;
        },

        async getUser() {
            if (!this.token) return
            const response = await axios.get('/api/user', {
                headers: { Authorization: `Bearer ${this.token}` },
            })
            this.user = response.data
        },

        async logout() {
            await axios.post('/api/logout', {}, {
                headers: { Authorization: `Bearer ${this.token}` },
            })
            this.token = null
            this.user = null
            localStorage.removeItem('token')
            router.push('/login')
        },
    },
})

Step 7 : Create Vue Components

Design the frontend UI using Bootstrap for styling. Create pages for login, registration, and some protected menus. Add a navigation menu and highlight active menu items to demonstrate how a true SPA behaves.

Create resources/js/App.vue

<template>
    <router-view />
</template>

Create resources/js/pages/Register.vue

<template>
    <section class="vh-100">
        <div class="container py-5 h-100">
            <div class="row d-flex justify-content-center align-items-center h-100">
                <div class="col-12 col-md-8 col-lg-6 col-xl-5">
                    <div class="card shadow-2-strong" style="border-radius: 1rem; background: lightsteelblue;">
                        <div class="card-body p-5">
                            <h1 class="mb-4 text-center fw-bold">Register</h1>
                            <form @submit.prevent="register">
                                <div class="form-outline mb-4">
                                    <input type="text"
                                        :class="['form-control form-control-lg', { 'is-invalid': errors?.name }]"
                                        placeholder="Name" ref="autofocus" v-model="form.name" :disabled="processing" />
                                    <span v-if="errors?.name" class="text-danger form-label" style="padding-left: 5px">
                                        {{ errors?.name[0] }}
                                    </span>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="text"
                                        :class="['form-control form-control-lg', { 'is-invalid': errors?.email }]"
                                        placeholder="Email" v-model="form.email" :disabled="processing" />
                                    <span v-if="errors?.email" class="text-danger form-label" style="padding-left: 5px">
                                        {{ errors?.email[0] }}
                                    </span>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="password"
                                        :class="['form-control form-control-lg', { 'is-invalid': errors?.password }]"
                                        placeholder="Password" v-model="form.password" :disabled="processing" />
                                    <span v-if="errors?.password" class="text-danger form-label"
                                        style="padding-left: 5px">
                                        {{ errors?.password[0] }}
                                    </span>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="password" class="form-control form-control-lg"
                                        placeholder="Confirm Password" v-model="form.password_confirmation"
                                        :disabled="processing" />
                                </div>
                                <div class="d-grid mb-2">
                                    <button class="btn btn-primary btn-lg" type="submit" :disabled="processing">
                                        <span v-show="processing" class="spinner-border spinner-border-sm" role="status"
                                            aria-hidden="true"></span>
                                        Register
                                    </button>
                                </div>
                            </form>
                            <div class="py-3 text-center fs-5">
                                Already have an account? <RouterLink :to="{ path: '/login' }">Login</RouterLink>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </section>
</template>

<script setup>
import { onMounted, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";

const processing = ref(false);
const form = ref({
    name: null,
    email: null,
    password: null,
    password_confirmation: null

});

const errors = ref(null);
const router = useRouter();
const autofocus = ref(null);
const register = async () => {
    processing.value = true;
    const auth = useAuthStore();
    const response = await auth.register(form.value);
    if (response.data.success) {
        router.push('/login');
    } else {
        errors.value = response.data.errors;;
        setTimeout(() => {
            autofocus.value.focus();
            autofocus.value.select();
        }, 5);
    }
    processing.value = false;
}

onMounted(() => {
    autofocus.value.focus();
});
</script>

Create resources/js/pages/Login.vue

<template>
    <section class="vh-100">
        <div class="container py-5 h-100">
            <div class="row d-flex justify-content-center align-items-center h-100">
                <div class="col-12 col-md-8 col-lg-6 col-xl-5">
                    <div class="card shadow-2-strong" style="border-radius: 1rem; background: lightsteelblue;">
                        <div class="card-body p-5">
                            <h1 class="mb-4 text-center fw-bold">Sign In</h1>
                            <form @submit.prevent="login">
                                <div class="form-outline mb-4">
                                    <input type="text"
                                        :class="['form-control form-control-lg', { 'is-invalid': errors?.email }]"
                                        placeholder="Email" ref="autofocus" v-model="form.email"
                                        :disabled="processing" />
                                    <span v-if="errors?.email" class="text-danger form-label" style="padding-left: 5px">
                                        {{ errors?.email[0] }}
                                    </span>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="password"
                                        :class="['form-control form-control-lg', { 'is-invalid': errors?.password }]"
                                        placeholder="Password" v-model="form.password" :disabled="processing" />
                                    <span v-if="errors?.password" class="text-danger form-label"
                                        style="padding-left: 5px">
                                        {{ errors?.password[0] }}
                                    </span>
                                </div>
                                <div class="d-grid mb-2">
                                    <button class="btn btn-primary btn-lg" type="submit" :disabled="processing">
                                        <span v-show="processing" class="spinner-border spinner-border-sm" role="status"
                                            aria-hidden="true"></span>
                                        Login
                                    </button>
                                </div>
                            </form>
                            <div class="py-3 text-center fs-5">
                                Don't have an account? <RouterLink :to="{ path: '/register' }">Register</RouterLink>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </section>
</template>

<script setup>
import { onMounted, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";

const processing = ref(false);
const form = ref({
    email: null,
    password: null

});

const errors = ref(null);
const router = useRouter();
const autofocus = ref(null);
const login = async () => {
    processing.value = true;
    const auth = useAuthStore();
    const response = await auth.login(form.value);
    if (response.data.success) {
        router.push('/');
    }
    else {
        errors.value = response.data.errors;
        setTimeout(() => {
            autofocus.value.focus();
            autofocus.value.select();
        }, 5);
    }
    processing.value = false;
}

onMounted(() => {
    autofocus.value.focus();
});
</script>

Create resources/js/pages/Layout.vue

<template>
    <div>
        <header
            class="text-bg-success d-flex flex-wrap align-items-center justify-content-center justify-content-md-between py-3 mb-4 border-bottom">
            <div class="col-md-3 mb-2 mb-md-0 d-flex">
                <a href="/" class="d-inline-flex link-body-emphasis text-decoration-none">
                    <strong class="fs-3 ms-2 text-white">Laravel Center</strong>
                </a>
            </div>
            <ul class="nav nav-underline col-12 col-md-auto mb-2 justify-content-end mb-md-0">
                <li class="nav-item">
                    <router-link :to="{ path: '/' }"
                        :class="['nav-link mx-2 text-white', { 'active': $route.path == '/' }]">Dashboard</router-link>
                </li>
                <li class="nav-item"><router-link :to="{ path: '/data' }"
                        :class="['nav-link mx-2 text-white', { 'active': $route.path == '/data' }]">Data</router-link>
                </li>
                <li class="nav-item"><router-link :to="{ path: '/operation' }"
                        :class="['nav-link mx-2 text-white', { 'active': $route.path == '/operation' }]">Operation</router-link>
                </li>
                <li class="nav-item">
                    <router-link :to="{ path: '/report' }"
                        :class="['nav-link mx-2 text-white', { 'active': $route.path == '/report' }]">Report</router-link>
                </li>
            </ul>
            <div class="col-md-3 text-end">
                Welcome, {{ auth.user?.name }}
                <button type="button" class="btn btn-danger me-2" v-if="auth.user"
                    @click="auth.logout()">Logout</button>
            </div>
        </header>
        <div class="container mt-5">
            <router-view></router-view>
        </div>
    </div>
</template>

<script setup>
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
</script>

Create resources/js/pages/Dashboard.vue

<template>
    <div>
        <h1 class="fw-bold">Dashboard</h1>
    </div>
</template>

Create resources/js/pages/Data.vue

<template>
    <div>
        <h1 class="fw-bold">Data</h1>
    </div>
</template>

Create resources/js/pages/Operation.vue

<template>
    <div>
        <h1 class="fw-bold">Operation</h1>
    </div>
</template>

Create resources/js/pages/Report.vue

<template>
    <div>
        <h1 class="fw-bold">Report</h1>
    </div>
</template>

Create resources/js/pages/404.vue

<template>
    <div class="d-flex align-items-center justify-content-center vh-100" style="background: whitesmoke">
        <div class="text-center">
            <h1 class="fw-bold" style="font-size: 200px">404</h1>
            <p class="fs-3"><span class="text-danger">Opps!</span> Page not found.</p>
            <p class="lead">The page you’re looking for doesn’t exist.</p>
            <router-link :to="{ path: '/' }" class="btn btn-primary pt-1">Go Home</router-link>
        </div>
    </div>
</template>

Step 8 : Configure Blade Entry File

In resources/views/welcome.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

    <!-- Styles / Scripts -->
    @vite('resources/js/app.js')
</head>

<body>
    <div id="app"></div>
</body>

</html>

Step 9 : Run, Test, and Verify Authentication

Finally, run both Laravel and Vue servers, test the registration and login flow, and verify that API requests use Sanctum tokens for secure laravel 12 vue 3 token based authentication between frontend and backend.

Start servers:

npm run dev
php artisan serve

Open http://127.0.0.1:8000

Summary

By following these steps, you’ve successfully built a complete laravel 12 vue 3 token based authentication system from scratch — including API creation, SPA setup, route protection, and a clean Bootstrap UI. This setup provides a strong foundation for developing modern, secure single-page applications with Laravel and Vue 3.

If you’d like to explore an alternative authentication approach, check out my related guide on
👉 Laravel 12 Vue 3 Session Based Authentication – Complete SPA Tutorial for Beginners
to learn how to implement secure session-based login using cookies instead of tokens.

Senghok
Senghok

Senghok is a web developer who enjoys working with Laravel and Vue.js. He creates easy-to-follow tutorials and guides to help beginners learn step by step. His goal is to make learning web development simple and fun for everyone.

Articles: 34

Newsletter Updates

Enter your email address below and subscribe to our newsletter

Leave a Reply

Your email address will not be published. Required fields are marked *