Create SPA authentication Using Laravel Sanctum and Vue.js

Harish Kumar · · 28845 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 Best Practices: Tips for Writing Clean and Maintainable Code

JavaScript is one of the most versatile and widely used programming languages today, powering everything from simple scripts to complex web applications. As the language continues (...)
Harish Kumar

Shallow Copy vs Deep Copy in JavaScript: Key Differences Explained

When working with objects and arrays in JavaScript, it's crucial to understand the difference between shallow copy and deep copy. These concepts dictate how data is duplicated (...)
Harish Kumar

A Beginner’s Guide to Efficient Memory Use in JavaScript

Managing memory efficiently in JavaScript applications is essential for smooth performance, especially for large-scale or complex applications. Poor memory handling can lead to (...)
Harish Kumar

Understanding the `.reduce()` Method in JavaScript

The .reduce() method in JavaScript is one of the most powerful array methods used for iterating over array elements and accumulating a single value from them. Whether you're summing (...)
Harish Kumar

Building a Real-Time Chat App with Laravel Reverb and Nuxt 3

Building a real-time chat application is a great way to understand the power of WebSockets and real-time communication. In this tutorial, we will walk through creating a Real-Time (...)
Harish Kumar

How to Use JavaScript’s .every() and .some() for Smarter Array Handling

JavaScript provides a variety of array methods that allow developers to perform common tasks in an efficient, clean, and readable manner. Among these methods are .every() and .some(), (...)
Harish Kumar