SPA authentication with laravel Sanctum and VueJs

Create a new laravel project by running

composer create-project --prefer-dist laravel/laravel:^10.0 "project-name"

Then navigate to the created project and open it with any IDE.


Then run

composer update && php artisan key:gen

Update .env file with follwing details

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=sanctum
DB_USERNAME=YourUserNameHere
DB_PASSWORD=YourPasswordHere

create a database by running

mysql -u root -p -e "create database sanctum;"
Let's first setup sanctum for the newly created laravel project.

By default sanctum is configured in new versions of laravel, But please check them with following steps.

Install sanctum

composer require laravel/sanctum

Then publish the vendor

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Run the migrations

php artisan migrate

update app/http/kernel.php

'api' => [
   \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, //uncomment this line
   \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
   \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Update config/cors.php

'paths' => [
    'api/*',
    '/login',
    '/logout',
    '/sanctum/csrf-cookie'
],

'supports_credentials' => true,
According to the laravel documentation(https://laravel.com/docs/10.x/sanctum#csrf-protection)

To authenticate your SPA, your SPA's "login" page should first make a request to the /sanctum/csrf-cookie endpoint to initialize CSRF protection for the application:

axios.get('/sanctum/csrf-cookie').then(response => {
    // Login...
});

During this request, Laravel will set an

XSRF-TOKEN

cookie containing the current CSRF token. This token should then be passed in an X-XSRF-TOKEN header on subsequent requests, which some HTTP client libraries like Axios and the Angular HttpClient will do automatically for you. If your JavaScript HTTP library does not set the value for you, you will need to manually set the X-XSRF-TOKEN header to ma-t-ch the value of the XSRF-TOKEN cookie that is set by this route.

Update User.php model

<?php

use Laravel\Sanctum\HasApiTokens;

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

Update your .env to have these lines

SESSION_DRIVER=cookie
SESSION_DOMAIN=localhost #port will not be a matter
SANCTUM_STATEFUL_DOMAIN=localhost:8000
if your browser request from 127.0.0.1 instead localhost, update exact same value in SESSION_DOMAIN

Update config/sanctum.php make sure to add client domain with port(if local) in stateful

'stateful' => explode(',', env(
        'SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,::1',
        env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
    )),

If you want you can ignore apis from authenticating

update app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
        // '/api/auth/login',
        // '/api/auth/register',
    ];

Update your api.php

use App\Http\Controllers\AuthController;

Route::middleware('auth:sanctum')->get('/auth/user', function (Request $request) {
    return $request->user();
});

Route::group([
    'prefix' => 'auth',
], function () {
    Route::post('register', [AuthController::class, 'register']);
    Route::post('login', [AuthController::class, 'login']);
    Route::post('logout', [AuthController::class, 'logout']);
});

Create AuthController by running

php artisan make:controller AuthController

And paste below in the AuthController

<?php

use App\Models\User;
use Illuminate\Http\Request;

class AuthController extends Controller
{
   public $loginAfterSignUp = true;

   public function register(Request $request)
   {
       $user = User::create([
           'name' => $request->name,
           'email' => $request->email,
           'password' => bcrypt($request->password),
       ]);

       $token = auth()->login($user);

       return response(['status' => 'success', 'data' => $user], 200);
   }

   public function login(Request $request)
   {
       $credentials = $request->only(['email', 'password']);

       if (!$token = auth()->attempt($credentials)) {
           return ['data' => ['errors' => ['validations' => ['user_name' => 'Invalid user name or password']]]];
       }
       $user = auth()->user();

       return $this->respondWithToken($token);
   }

   public function logout()
   {
       auth()->logout();

       return response()->json(['message' => 'Successfully logged out']);
   }

   public function refresh()
   {
       return $this->respondWithToken(auth()->refresh());
   }

   protected function respondWithToken($token)
   {
       $user = auth()->user();

       return response()->json([
           'access_token' => $token,
           'user' => $user,
       ]);
   }
}
Now let's create frontend for this application

Open a terminal window from the project root and execute below commands

npm install vue@next vue-loader@next
npm i @vitejs/plugin-vue
npm install vue-router
npm install vuex --save
npm install axios

//in order to persist some information in browser local storage.
npm i vuex-persistedstate

Update your vite.config.json file

<?php

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

export default defineConfig({
    plugins: [
        vue(),
        laravel({
            input: ['resources/js/app.js'],
            refresh: true,
        }),
    ],
});

Install tailwind css

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

And update your tailwind.config.json

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./resources/**/*.blade.php",
    "./resources/**/*.js",
    "./resources/**/*.vue",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Update your resources.boostrap.js file

import axios from 'axios';
window.axios = axios;

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

window.axios.defaults.withCredentials = true

Now update your app.js

import './bootstrap';
import '../css/app.css';

import { createApp } from 'vue'

import app from './Components/App.vue'

import axios from 'axios';

axios.defaults.baseURL = 'http://127.0.0.1:8000/'

createApp(app).mount("#app")

Now we need to create that App.vue in resources/js/Components/

Create new file called App.vue

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

We have to render this vue component in our welcome.blade.php

So first of all update web.php route file

Route::get('/{any}', function () {
    return view('welcome    ');
})->where('any', '^(?!api/)[/\w.-]*');

And then the welcome.blade.php

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Vue3 + Laravel9</title>

    @vite('resources/css/app.css')
</head>
<body>
    <div id="app"></div>

    @vite('resources/js/app.js')
</body>
</html>

Now the basic structure is completed, now we are going to apply authentication components and logics here

You can check if the application working by running php artisan serve and npm run dev on two terminal windows

If it works, move on; otherwise, do some debugging.

Create the resources/js/store directory and auth.js and index.js in it.

Update index.js

<?php

import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import auth from './auth.js'

const store = createStore({
    plugins:[
        createPersistedState()
    ],
    modules:{
        auth
    }
})

export default store

Update auth.js

<?php

import axios from 'axios'
import router from '../router.js'

export default {
    namespaced: true,
    state:{
        authenticated:false,
        user:{}
    },
    getters:{
        authenticated(state){
            return state.authenticated
        },
        user(state){
            return state.user
        }
    },
    mutations:{
        SET_AUTHENTICATED (state, value) {
            state.authenticated = value
        },
        SET_USER (state, value) {
            state.user = value
        }
    },
    actions:{
        login({commit}){
            return axios.get('/api/auth/user').then((response)=>{
                commit('SET_USER',response.data)
                commit('SET_AUTHENTICATED',true)
                router.push({ name: 'dashBoard', params: {} }).catch(error => {
                    console.info(error.message)
                 })
                // this.$router.push({ name: 'dashBoard', params: {} }).catch(() => {});

            }).catch((error)=>{
                console.error("An error occurred:", error);
                commit('SET_USER',{})
                commit('SET_AUTHENTICATED',false)
            })
        },
        logout({commit}){
            commit('SET_USER',{})
            commit('SET_AUTHENTICATED',false)
        }
    }
}

Create resources/js/router.js and paste below

<?php

import { createWebHistory, createRouter } from 'vue-router'
import store from '@/store'

const Login = () => import('./Components/Login.vue')
const Register = () => import('./Components/Register.vue')
const Dashboard = () => import('./Components/DashBoard.vue')


const routes = [
    {
        name: "login",
        path: "/login",
        component: Login,
        meta: {
            middleware: "guest",
            title: `Login`
        }
    },
    {
        name: "register",
        path: "/register",
        component: Register,
        meta: {
            middleware: "guest",
            title: `Register`
        }
    },
    {
        name: "dashBoard",
        path: "/",
        component: Dashboard,
        meta: {
            middleware: "auth"
        },
    },
    {
        name: "dashBoard",
        path: "/dashBoard",
        component: Dashboard,
        meta: {
            middleware: "auth"
        },
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes, // short for `routes: routes`
})

const state = {
    isRedirecting: false
}

router.beforeEach((to, from, next) => {
    if (!state.isRedirecting && to.meta.middleware == "guest") {
        state.isRedirecting = true;
        if (store.state.auth.authenticated) {
            next({ name: "dashBoard" })
        }
        next()
    } else {
        state.isRedirecting = false;
        if (store.state.auth.authenticated) {
            next()
        } else {
            next({ name: "login" })
        }
    }
})

export default router

Update your app.js

import './bootstrap';
import '../css/app.css';

import store from './store/index'

import { createApp } from 'vue'

import router from './router'

import app from './Components/App.vue'

import axios from 'axios';

axios.defaults.baseURL = 'http://127.0.0.1:8000/'

createApp(app).use(router).use(store).mount("#app")

Now create resources/js/Components/Login.vue and paste

<template>
    <form>
        Email:<input type="email" v-model="email" />

        Password:<input type="password" v-model="password" />

        <button type="submit" @click.prevent="login">Login</button>
    </form>
</template>

<script>
    import { mapActions } from "vuex";

    export default {
        name: "login",

        data() {
            return {
                token: "",
                email: "",
                password: "",
                errors: {},
            };
        },

        mounted() {},

        methods: {
            ...mapActions({
                signIn: "auth/login",
            }),
            async login() {
                let credentials = {
                    email: this.email,
                    password: this.password,
                };
                await axios.get("/sanctum/csrf-cookie");

                await axios
                    .post("/api/auth/login", credentials)
                    .then(({ data }) => {
                        this.signIn();
                    })
                    .catch(({ response }) => {
                        if (response.status === 422) {
                            this.validationErrors = response.data.errors;
                        } else {
                            this.validationErrors = {};
                            alert(response.data.message);
                        }
                    })
                    .finally(() => {
                        this.processing = false;
                    });
            },
        },
    };
</script>

And create resources/js/Components/Register.vue

<template>
    <form>
        User Name:<input type="text" v-model="user_name" />

        Email:<input type="email" v-model="email" />

        Password:<input type="password" v-model="password" />

        <button type="submit" @click.prevent="register">Register</button>
    </form>
</template>
<script>
    import { mapActions } from "vuex";

    export default {
        data() {
            return {
                user_name: "",
                email: "",
                password: "",
                errors: {},
            };
        },

        mounted() {},

        methods: {
            ...mapActions({
                signIn: "auth/login",
            }),
            async register() {
                let credentials = {
                    name: this.user_name,
                    email: this.email,
                    password: this.password,
                    password_confirmation: this.password,
                };

                await axios.get("/sanctum/csrf-cookie");

                await axios
                    .post("/api/auth/register", credentials)
                    .then(response => {
                        this.validationErrors = {};
                        this.signIn();
                    })
                    .catch(({ response }) => {
                        if (response.status === 422) {
                            this.validationErrors = response.data.errors;
                        } else {
                            this.validationErrors = {};
                            alert(response.data.message);
                        }
                    })
                    .finally(() => {
                        this.processing = false;
                    });
            },
        },
    };
</script>

Update App.vue

<template>
    <div>
        <div class="mb-5">
            <nav class="navbar fixed-top bg-gray-800 text-white mb-5">
                <ul class="navbar-nav flex items-center justify-end flex-wrap py-4 px-8 space-x-4" style="width: 100%">
                    <li class="nav-item {{ $route.name === 'dashboard' ? 'active' : '' }}">
                        <router-link to="/dashboard" class="nav-link font-semibold hover:text-blue-500"> Home</router-link>
                    </li>
                    <li class="nav-item {{ $route.name === 'login' ? 'active' : '' }}">
                        <router-link to="/login" class="nav-link font-semibold hover:text-blue-500"> SignIn</router-link>
                    </li>
                    <li class="nav-item {{ $route.name === 'register' ? 'active' : '' }}">
                        <router-link to="/register" class="nav-link font-semibold hover:text-blue-500"> SignUp</router-link>
                    </li>
                    <li class="nav-item">
                        <a class="dropdown-item" href="javascript:void(0)" @click="logout"> Logout</a>
                    </li>
                </ul>
            </nav>
        </div>
        <div>
            <router-view> </router-view>
        </div>
    </div>
</template>

<script>
    import { mapActions } from "vuex";

    export default {
        name: "App.vue",
        data() {
            return {
                user: this.$store.state.auth.user,
            };
        },
        mounted() {
            console.log("user: ", this.$store.state.auth.user);
        },

        methods: {
            ...mapActions({
                signOut: "auth/logout",
            }),
            async logout() {
                await axios.post("/api/auth/logout").then(({ data }) => {
                    this.signOut();
                    this.$router.push({ name: "login" });
                });
            },
        },
    };
</script>

Finally create resources/js/Components/DashBoard.vue

<template>
    <div>
        You are in dashboard
    </div>
</template>