Create SPA authentication Using Laravel Sanctum and Vue.js

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

Vuex Tutorial: Learn State management in Vue.js using Vuex

The objective of this Vuex tutorial is to give you an essential understanding of state management in Vue.js using Vuex by creating a relatable example. By the end of this tutorial, (...)
Harish Kumar

What are Laravel Macros and How to Extending Laravel’s Core Classes using Macros with example?

Laravel Macros are a great way of expanding Laravel's core macroable classes and add additional functionality needed for your application. In simple word, Laravel Macro is an (...)
Harish Kumar

Install Laravel Valet Linux+ development environment on Ubuntu System

The official Laravel Valet development environment is great if you are an Apple user. But there is no official Valet for Linux or Window system.
Harish Kumar

Laravel Sanctum API Token Authentication Tutorial with example

Laravel Sanctum is a popular package for API Token Authentication. There are many other packages available to authenticate the APIs request in Laravel. For example, We are already (...)
Harish Kumar

Create API Authentication with Laravel Passport

In this article, we'll see how to implement restful API authentication using Laravel Passport. You should have experience working with Laravel as this is not an introductory tutorial. (...)
Sumit Talwar

Laravel Themer: multi-theme support for Laravel application

This Laravel Themer package adds multi-theme support to your application. This theme package improves any application while allowing the freedom to organize and maintain your app's (...)
Harish Kumar