Create SPA authentication Using Laravel Sanctum and Vue.js

Harish Kumar · · 27011 Views

In this guide, we will focus on SPA authentication in a simple Vue.js app using Laravel Sanctum. Laravel Sanctum provides a featherweight authentication system for SPAs (single page applications), mobile applications, and simple, token-based APIs.

How does Laravel Sanctum work?

Laravel Sanctum utilizes Laravel's cookie-based session authentication to verify users. Here's the workflow: 

  1. First, request a CSRF cookie from Sanctum, which permits you to make CSRF-protected requests to normal endpoints. 

  2. Now, request the /login endpoint. It issues a cookie that has the user's session.

  3. Any requests to API now include this cookie, so the user is authenticated for the lifetime of that session.

Create a Laravel Project

Create a new Laravel project by running either of the following commands on your terminal:

laravel new [name] 

# or 

composer create-project — prefer-dist laravel/laravel [name]

Run the following command to serve Laravel locally.

php artisan serve

In order to authenticate, your SPA and API must share the same top-level domain. However, they may be placed on different subdomains.

So, here don't use 127.0.0.1, use localhost instead. We will use this later on when we configure our Sanctum domains and CORS origins.

Now, add database credentials in the .env file as showing below.

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

Authentication with laravel/ui

laravel/ui package is optional you can skip this. I am using this because it is going to create quick authentication scaffolding that will save some time. 

Run the following command to install laravel/ui package:

composer require laravel/ui

Then generate the authentication scaffolding:

php artisan ui bootstrap --auth

Install Laravel Sanctum

Run the following command to install laravel/sanctum package:

composer require laravel/sanctum

Now publish the configuration files and migrations.

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

Run the migrations:

php artisan migrate

Add Sanctum’s Middleware

Now add the EnsureFrontendRequestsAreStateful middleware to your api middleware group, in app/Http/Kernel.php.

use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

'api' => [
    EnsureFrontendRequestsAreStateful::class,
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

This ensures that requests made to API can utilize session cookies since that is the way Sanctum authenticates when making requests.

Configure Sanctum

Open up the config/sanctum.php file. It's important that we set the stateful key to contain a list of domains that we're accepting authenticated requests from.

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),

Luckily, localhost is already in there, so we're all set. You should change this when deploying to production, so you must add SANCTUM_STATEFUL_DOMAINS to your .env file with a comma-separated list of allowed domains.

Change the session driver

In .env, update your session driver to cookie.

SESSION_DRIVER=cookie

Configure CORS

Head over to your config/cors.php config file and update the paths to look like this:

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

Also, set the supports_credentials option to true.

'supports_credentials' => true

Sanctum middleware

Right now, in routes/api.php, we have the auth:api middleware set for the example API route Laravel provides. This won't do, we'll need Sanctum to get the session cookie and to validate if a user is authenticated or not.

So, use the auth:sanctum middleware instead:

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

Install Vue CLI and create a project

If you don't already have the Vue CLI installed, it's simple:

npm install -g @vue/cli

Then create a new project.

vue create app-name

Once that's installed, go into the Vue project directory and run the npm run serve command to get your Vue up and running.

Notice we're on localhost once more. Our Laravel API and Vue app match up so we shouldn't run into any issues.

Install Bootstrap in the Vue App

Run the following command to install bootstrap, jquery and popper.js:

npm install jquery popper.js bootstrap

Now, import bootstrap in src/main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";

import "bootstrap";
import "bootstrap/dist/css/bootstrap.min.css";

Vue.config.productionTip = false;

new Vue({
  router,
  render: h => h(App)
}).$mount("#app");

In the above snippet, you may have noticed that it has imported ./router, which is not created yet. So, our next step is to create routes.

Create src/router/index.js and add the following snippet in that file.

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import Dashboard from "../views/Dashboard.vue";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home
  },
  {
    path: "/login",
    name: "Login",
    component: Login,
    meta: { guestOnly: true }
  },
  {
    path: "/register",
    name: "Register",
    component: Register,
    meta: { guestOnly: true }
  },
  {
    path: "/dashboard",
    name: "Dashboard",
    component: Dashboard,
    meta: { authOnly: true }
  }
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes
});

function isLoggedIn() {
  return localStorage.getItem("auth");
}

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.authOnly)) {
    // this route requires auth, check if logged in
    // if not, redirect to login page.
    if (!isLoggedIn()) {
      next({
        path: "/login",
        query: { redirect: to.fullPath }
      });
    } else {
      next();
    }
  } else if (to.matched.some(record => record.meta.guestOnly)) {
    // this route requires auth, check if logged in
    // if not, redirect to login page.
    if (isLoggedIn()) {
      next({
        path: "/dashboard",
        query: { redirect: to.fullPath }
      });
    } else {
      next();
    }
  } else {
    next(); // make sure to always call next()!
  }
});

export default router;

In this route, we have routes for home, login, register, and dashboard. So, our next step is to create components for these pages.

Create the Home component

Clear our the views/Home.vue component, so you just have a plain homepage.

<template>
  <div>
    Home
  </div>
</template>

<script>
  export default {
    name: 'Home',
    components: {
      //
    }
  }
</script>

Create a Login in page

Create a new file, views/Login.vue with a simple login-in form.

<template>
  <div class="home col-5 mx-auto py-5 mt-5">
    <h1 class="text-center">Login</h1>
    <div class="card">
      <div class="card-body">
        <div class="form-group">
          <label for="email">Email address:</label>
          <input
            type="email"
            v-model="form.email"
            class="form-control"
            id="email"
          />
          <span class="text-danger" v-if="errors.email">
            {{ errors.email[0] }}
          </span>
        </div>
        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            v-model="form.password"
            class="form-control"
            id="password"
          />
          <span class="text-danger" v-if="errors.password">
            {{ errors.password[0] }}
          </span>
        </div>
        <button @click.prevent="login" class="btn btn-primary btn-block">
          Login
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import User from "../apis/User";
export default {
  data() {
    return {
      form: {
        email: "",
        password: ""
      },
      errors: []
    };
  },
  methods: {
    login() {
      User.login(this.form)
        .then(() => {
          this.$root.$emit("login", true);
          localStorage.setItem("auth", "true");
          this.$router.push({ name: "Dashboard" });
        })
        .catch(error => {
          if (error.response.status === 422) {
            this.errors = error.response.data.errors;
          }
        });
    }
  }
};
</script>

Create a Register page

Create a new file, views/Register.vue with a simple register form.

<template>
  <div class="home col-5 mx-auto py-5 mt-5">
    <h1 class="text-center">Register</h1>
    <div class="card">
      <div class="card-body">
        <div class="form-group">
          <label for="name">Name:</label>
          <input
            type="text"
            v-model="form.name"
            class="form-control"
            id="name"
          />
          <span class="text-danger" v-if="errors.name">
            {{ errors.name[0] }}
          </span>
        </div>
        <div class="form-group">
          <label for="email">Email address:</label>
          <input
            type="email"
            v-model="form.email"
            class="form-control"
            id="email"
          />
          <span class="text-danger" v-if="errors.email">
            {{ errors.email[0] }}
          </span>
        </div>
        <div class="form-group">
          <label for="password">Password:</label>
          <input
            type="password"
            v-model="form.password"
            class="form-control"
            id="password"
          />
          <span class="text-danger" v-if="errors.password">
            {{ errors.password[0] }}
          </span>
        </div>
        <div class="form-group">
          <label for="password_confirmation">Confirm Password:</label>
          <input
            type="password"
            v-model="form.password_confirmation"
            class="form-control"
            id="password_confirmation"
          />
          <span class="text-danger" v-if="errors.password_confirmation">
            {{ errors.password_confirmation[0] }}
          </span>
        </div>
        <button
          type="submit"
          @click.prevent="register"
          class="btn btn-primary btn-block"
        >
          Register
        </button>
      </div>
    </div>
  </div>
</template>

<script>
import User from "../apis/User";
export default {
  data() {
    return {
      form: {
        name: "",
        email: "",
        password: "",
        password_confirmation: ""
      },
      errors: []
    };
  },
  methods: {
    register() {
      User.register(this.form)
        .then(() => {
          this.$router.push({ name: "Login" });
        })
        .catch(error => {
          if (error.response.status === 422) {
            this.errors = error.response.data.errors;
          }
        });
    }
  }
};
</script>

Create a Dashboard page

Clear our the views/Dashboard.vue component, so you just have a dashboard page.

<template>
  <div class="home col-8 mx-auto py-5 mt-5">
    <h1>Dashboard</h1>
    <div class="card">
      <div class="card-body" v-if="user">
        <h3>Hello, {{ user.name }}</h3>
        <span>{{ user.email }}</span>
      </div>
    </div>
  </div>
</template>

<script>
import User from "../apis/User";
export default {
  data() {
    return {
      user: null
    };
  },
  mounted() {
    User.auth().then(response => {
      this.user = response.data;
    });
  }
};
</script>

Handling API

While building up a web application, we need to make some API calls to get or update the data we are using. Typically, the calls are called directly into the code. It might happen that in another file we need to do precisely the same request so we simply use the same code. This a common situation with repetitive code. So if something changed in the server, we need to update two functions in two files: that is inconvenient.

For a better solution, create a src/apis/Api.js file and add the following code:

import axios from "axios";

let Api = axios.create({
  baseURL: "http://localhost:8000/api"
});

Api.defaults.withCredentials = true;

export default Api;

Next, create a src/apis/Csrf.js  file and the following code:

import Api from "./Api";
import Cookie from "js-cookie";

export default {
  getCookie() {
    let token = Cookie.get("XSRF-TOKEN");

    if (token) {
      return new Promise(resolve => {
        resolve(token);
      });
    }

    return Api.get("/csrf-cookie");
  }
};

Next, to handle user APIs, create src/apis/User.js

import Api from "./Api";
import Csrf from "./Csrf";

export default {
  async register(form) {
    await Csrf.getCookie();

    return Api.post("/register", form);
  },

  async login(form) {
    await Csrf.getCookie();

    return Api.post("/login", form);
  },

  async logout() {
    await Csrf.getCookie();

    return Api.post("/logout");
  },

  auth() {
    return Api.get("/user");
  }
};

Finally, we have created the SPA app with Vue.js and Laravel Sanctum. Run npm run serve command and try this app in your browser.

1

Please login or create new account to add your comment.

1 comment
Kamal Kunwar
Kamal Kunwar ·

Hello, I have followed your tutorial and almost set up on my local too. But I got a error because I am using laravel 8 and vue 3 and bootstrap 5. I manage to fix other error except one - which is as below on console. Uncaught TypeError: this.$root.$on is not a function on Navigation.vue because $on is removed on vue 3 here

mounted() {
    this.$root.$on("login", () => {
      this.isLoggedIn = true;
    });

and another warning on console is

[Vue warn]: Unhandled error during execution of mounted hook 
  at <Navigation> 
  at <App>

I hope you will help me out. regards.

You may also like:

Secure Your SPA with Laravel Sanctum: A Step-by-Step Guide

In today's web development landscape, Single Page Applications (SPAs) are increasingly popular. But securing their interaction with backend APIs is crucial. Laravel Sanctum provides (...)
Harish Kumar

JavaScript Array Destructuring: Unpacking Arrays with Ease

JavaScript array destructuring is a powerful feature introduced in ES6 (ECMAScript 2015) that simplifies the process of extracting values from arrays. It allows you to unpack an (...)
Harish Kumar

Multi-Authentication with Guards in Laravel

Laravel's robust authentication system provides a powerful mechanism for securing your application. To cater to scenarios where you need different user roles with distinct login (...)
Harish Kumar

Arrow Functions: The Modern Way to Write JavaScript

JavaScript Arrow Functions, introduced in ES6 (ECMAScript 2015), have become a fundamental part of modern JavaScript development. They offer a concise and elegant way to write (...)
Harish Kumar

Essential JavaScript Tips for Better Coding - A Guide to JavaScript Best Practices

In the ever-evolving landscape of web development, the significance of JavaScript cannot be overstated. As the backbone of interactive websites, mastering JavaScript is essential (...)
Harish Kumar

From Beginner to Pro: Master JavaScript with This Comprehensive Guide (ES2015-ES2023)

Calling all aspiring and seasoned developers! I'm ecstatic to announce the release of my comprehensive eBook, "JavaScript: A Comprehensive Guide from ES2015 to ES2023". This in-depth (...)
Harish Kumar