Full Laravel 12 + Vue 3 SPA Authentication Tutorial Using JWT

Full Laravel 12 + Vue 3 SPA Authentication Tutorial Using JWT

Introduction

In modern web development, APIs are the backbone of communication between clients such as web browsers, mobile applications, and backend servers. Because APIs often expose sensitive data and critical functionality, implementing secure authentication and authorization is essential.

One widely adopted solution for API authentication is JSON Web Token (JWT). JWT provides a simple, secure, and stateless way to authenticate users by transmitting signed tokens between the client and the server. This approach is especially popular in Single Page Applications (SPAs) and mobile apps, where traditional session-based authentication is not ideal.

In this tutorial, you will learn how to build a Laravel 12 + Vue 3 SPA authentication system using JWT, following best practices and a clean project structure suitable for real-world applications.

What is JWT?

JSON Web Token (JWT) is an open standard (RFC 7519) used to securely transmit information as a JSON object between two parties. A JWT is:

  • Self-contained – it contains user identity and claims

  • Compact – easy to send in HTTP headers

  • Digitally signed – ensures data integrity

  • Stateless – no server-side session storage required

JWTs are commonly used to authenticate users in web applications, mobile apps, and public APIs, where each request includes a token in the Authorization: Bearer <token> header. This allows the server to verify the user's identity without maintaining session data.

Prerequisites

Before starting this tutorial, make sure you have the following installed:

  • PHP 8.1 or higher

  • Composer

  • Node.js & npm

  • MySQL

  • Basic knowledge of Laravel and Vue 3

Step 1: Create Laravel Project

composer create-project laravel/laravel laravel-vue-jwt "12.*" cd laravel-vue-jwt

Step 2: Install JWT Package

Install the php-open-source-saver/jwt-auth package:

composer require php-open-source-saver/jwt-auth -W

Publish the package configuration:

php artisan vendor:publish --provider="PHPOpenSourceSaver\JWTAuth\Providers\LaravelServiceProvider"

Generate the JWT secret key (add to .env):

php artisan jwt:secret

Open .env and set:

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

(Change DB settings if needed)

Step 3: Run the migration command

php artisan migrate
php artisan install:api

Step 4: Update User Model

Edit app/Models/User.php to implement JWTSubject:

<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject; class User extends Authenticatable implements JWTSubject { protected $fillable = ['name', 'email', 'password']; protected $hidden = ['password', 'remember_token']; public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } }

Step 5: Configure Authentication Guard

Open config/auth.php and update the api guard to use jwt driver:

'guards' => [ // other guards... 'api' => [ 'driver' => 'jwt', 'provider' => 'users', 'hash' => false, ], ],

Step 6: Create Auth Controller

Generate the controller:

php artisan make:controller API/AuthController

Edit app/Http/Controllers/API/AuthController.php:

<?php namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Http\Requests\RegisterRequest; use App\Http\Requests\LoginRequest; use App\Http\Resources\UserResource; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use App\Models\User; use Illuminate\Http\Request;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; class AuthController extends Controller { // Register new user and return token public function register(RegisterRequest $request) { $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), ]); $token = JWTAuth::fromUser($user); return response()->json([ 'user' => new UserResource($user), 'token' => $token, ]); } // Login user and return token public function login(LoginRequest $request) { $credentials = $request->only('email', 'password'); if (!$token = JWTAuth::attempt($credentials)) { return response()->json(['message' => 'Invalid credentials'], 401); } $user = Auth::user(); return response()->json([ 'user' => new UserResource($user), 'token' => $token, ]); } // Return authenticated user info public function user(Request $request) { return new UserResource($request->user()); } // Logout (invalidate token) public function logout() { JWTAuth::invalidate(JWTAuth::getToken()); return response()->json(['message' => 'Logged out']); } }

Step 7: Create Form Request Validations

php artisan make:request RegisterRequest php artisan make:request LoginRequest

Edit app/Http/Requests/RegisterRequest.php:

public function rules() { return [ 'name' => 'required|string|max:255', 'email' => 'required|email|unique:users,email', 'password' => 'required|confirmed|min:8', ]; }

Edit app/Http/Requests/LoginRequest.php:

public function rules() { return [ 'email' => 'required|email', 'password' => 'required|string', ]; }

Step 8: Create User API Resource

php artisan make:resource UserResource

Edit app/Http/Resources/UserResource.php:

public function toArray($request) { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, ]; }

Step 9: Define API Routes

Edit routes/api.php:

use App\Http\Controllers\API\AuthController; Route::post('/register', [AuthController::class, 'register']); Route::post('/login', [AuthController::class, 'login']); Route::middleware('auth:api')->group(function () { Route::get('/user', [AuthController::class, 'user']); Route::post('/logout', [AuthController::class, 'logout']); });

Step 10: Create Vue Project Directories & Files

mkdir -p resources/js/router mkdir -p resources/js/components mkdir -p resources/js/stores mkdir -p resources/js/services touch resources/js/app.js touch resources/js/App.vue touch resources/js/router/index.js touch resources/js/stores/auth.js touch resources/js/services/api.js touch resources/js/components/Login.vue touch resources/js/components/Register.vue touch resources/js/components/Dashboard.vue

Step 11: Install Vue 3 Dependencies

npm install vue vue-router@4 pinia npm install --save-dev @vitejs/plugin-vue

Step 12: Configure Vite

Edit vite.config.js:

import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import vue from '@vitejs/plugin-vue'; import path from 'path'; export default defineConfig({ plugins: [ laravel({ input: ['resources/js/app.js'], refresh: true, }), vue(), ], resolve: { alias: { '@': path.resolve(__dirname, 'resources/js'), }, }, });

Step 13: Vue Router Setup

Edit resources/js/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'; import Login from '@/components/Login.vue'; import Register from '@/components/Register.vue'; import Dashboard from '@/components/Dashboard.vue'; import { useAuthStore } from '@/stores/auth'; const routes = [ { path: '/login', component: Login, meta: { guest: true } }, { path: '/register', component: Register, meta: { guest: true } }, { path: '/', component: Dashboard, meta: { requiresAuth: true } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); router.beforeEach((to, from, next) => { const auth = useAuthStore(); if (to.meta.requiresAuth && !auth.isLoggedIn) next('/login'); else if (to.meta.guest && auth.isLoggedIn) next('/'); else next(); }); export default router;

Step 14: Pinia Store Setup

Edit resources/js/stores/auth.js:

import { defineStore } from 'pinia'; export const useAuthStore = defineStore('auth', { state: () => ({ user: null, token: localStorage.getItem('token') || null, }), getters: { isLoggedIn: (state) => !!state.user && !!state.token, }, actions: { setUser(response) { this.user = response.user ?? null; this.token = response.token ?? null; if (this.token) { localStorage.setItem('token', this.token); } else { localStorage.removeItem('token'); } }, async fetchUser() { if (!this.token) { this.user = null; return; } try { const res = await fetch('/api/user', { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${this.token}`, }, }); if (!res.ok) throw new Error(); const data = await res.json(); this.user = data.data; } catch { this.user = null; this.token = null; localStorage.removeItem('token'); } }, async logout() { if (!this.token) return; await fetch('/api/logout', { method: 'POST', headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${this.token}`, }, }); this.user = null; this.token = null; localStorage.removeItem('token'); }, }, });

Step 15: API Service Helper

Edit resources/js/services/api.js:

export async function apiRequest(url, options = {}) { const token = localStorage.getItem('token'); const headers = options.headers || {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } headers['Accept'] = 'application/json'; const response = await fetch(url, { ...options, headers, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'API Error'); } return response.json(); }

Step 16: Vue Entry Point

Edit resources/js/app.js:

import { createApp } from 'vue'; import { createPinia } from 'pinia'; import router from './router'; import App from './App.vue'; const app = createApp(App); app.use(createPinia()); app.use(router); app.mount('#app');

Step 17: Main Vue Component

Edit resources/js/App.vue:

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

Step 18: Vue Components (Login, Register, Dashboard)

Login.vue

<template> <div class="auth-container"> <h2>Login</h2> <input v-model="email" type="email" placeholder="Email" /> <input v-model="password" type="password" placeholder="Password" /> <button @click="handleLogin" :disabled="loading"> {{ loading ? 'Logging in...' : 'Login' }} </button> <p v-if="message" class="message">{{ message }}</p> <p> Don't have an account? <router-link to="/register">Register here</router-link> </p> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { apiRequest } from '@/services/api'; const router = useRouter(); const auth = useAuthStore(); const email = ref(''); const password = ref(''); const message = ref(''); const loading = ref(false); async function handleLogin() { message.value = ''; loading.value = true; try { const user = await apiRequest('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.value, password: password.value, }), }); auth.setUser(user); router.push('/'); } catch (error) { message.value = error.message || 'Login failed'; } finally { loading.value = false; } } </script> <style scoped> .auth-container { max-width: 400px; margin: 2rem auto; display: flex; flex-direction: column; gap: 1rem; } input { padding: 0.5rem; font-size: 1rem; } button { padding: 0.6rem; font-size: 1rem; cursor: pointer; } .message { color: red; } </style>

Register.vue

<template> <div class="auth-container"> <h2>Register</h2> <input v-model="name" type="text" placeholder="Name" /> <input v-model="email" type="email" placeholder="Email" /> <input v-model="password" type="password" placeholder="Password" /> <input v-model="passwordConfirm" type="password" placeholder="Confirm Password" /> <button @click="handleRegister" :disabled="loading"> {{ loading ? 'Registering...' : 'Register' }} </button> <p v-if="message" class="message">{{ message }}</p> <p> Already have an account? <router-link to="/login">Login here</router-link> </p> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { apiRequest } from '@/services/api'; const router = useRouter(); const auth = useAuthStore(); const name = ref(''); const email = ref(''); const password = ref(''); const passwordConfirm = ref(''); const message = ref(''); const loading = ref(false); async function handleRegister() { message.value = ''; if (password.value !== passwordConfirm.value) { message.value = 'Passwords do not match'; return; } loading.value = true; try { await apiRequest('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, password: password.value, password_confirmation: passwordConfirm.value, }), }); router.push('/login'); } catch (error) { message.value = error.message || 'Registration failed'; } finally { loading.value = false; } } </script> <style scoped> .auth-container { max-width: 400px; margin: 2rem auto; display: flex; flex-direction: column; gap: 1rem; } input { padding: 0.5rem; font-size: 1rem; } button { padding: 0.6rem; font-size: 1rem; cursor: pointer; } .message { color: red; } </style>

Dashboard.vue

<template> <div class="dashboard-container"> <h2>Welcome, {{ auth.user?.name || 'User' }}</h2> <p>Email: {{ auth.user?.email }}</p> <button @click="handleLogout" :disabled="loading"> {{ loading ? 'Logging out...' : 'Logout' }} </button> </div> </template> <script setup> import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; const router = useRouter(); const auth = useAuthStore(); const loading = ref(false); async function handleLogout() { loading.value = true; try { await auth.logout(); router.push('/login'); } catch (error) { alert('Logout failed.'); } finally { loading.value = false; } } </script> <style scoped> .dashboard-container { max-width: 600px; margin: 2rem auto; text-align: center; } button { padding: 0.6rem 1.2rem; font-size: 1rem; cursor: pointer; margin-top: 1rem; } </style>

Step 19: Blade View to Load Vue SPA

Edit routes/web.php:

use Illuminate\Support\Facades\Route; Route::get('/{any}', function () { return view('welcome'); })->where('any', '.*');

Create resources/views/welcome.blade.php:

<!DOCTYPE html> <html lang="en"> <head> <title>Laravel Vue SPA Passport Auth</title> @vite('resources/js/app.js') </head> <body> <div id="app"></div> </body> </html>

Step 20: Run Your Project

Open two terminals:

Start Laravel backend:

php artisan serve

Start frontend dev server:

npm run dev

Step 21: Visit Your App

Open browser:

http://localhost:8000

You now have a secure SPA with:

Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

Post a Comment

CAN FEEDBACK
close