Create SPA authentication Using Laravel Sanctum and Vue.js

Harish Kumar · · 28089 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:

JavaScript Array .filter(): A Comprehensive Tutorial

JavaScript offers several powerful methods to manipulate arrays, and .filter() is one of the most versatile and commonly used. This tutorial will guide you through the basics of (...)
Harish Kumar

How to Use DTOs for Cleaner Code in Laravel, Best Practices and Implementation Guide

When developing APIs in Laravel, ensuring your responses are clear, concise, and consistent is crucial for creating a maintainable and scalable application. One effective way to (...)
Harish Kumar

Vue 3: Best Practices for Efficient and Scalable Development

Vue.js is a popular JavaScript framework for building user interfaces. It has several features that enhance the development process and performance of applications. This guide (...)
Harish Kumar

Data Type Validation in Laravel Collections with the `ensure()` Method

Before moving on to the ensure() method, let us first know what Laravel Collections actually are. These are wrappers of PHP arrays, offering a fluent and helpful interface in interacting (...)
Harish Kumar

JavaScript's Array .forEach() Method Explained: Tips, Tricks, and Examples

The array .forEach() method is one of the most powerful tools in JavaScript, used for executing a provided function once upon an array element. Common applications involve iterating (...)
Harish Kumar

PHP Generators: Efficient Data Handling and Iteration Techniques

PHP Generators offer a powerful and memory-efficient way to handle large datasets and complex iteration scenarios in your applications. They provide a more elegant solution compared (...)
Harish Kumar