DEV Community

Cover image for Building Vue.js Client SPA Token-Based Authentication with Laravel Sanctum
Roman Paprotsky
Roman Paprotsky

Posted on • Edited on

Building Vue.js Client SPA Token-Based Authentication with Laravel Sanctum

Authentication systems are a vital part of most modern applications, and should thus be appropriately implemented.

In this article, you will learn how to build an authentication system using Vue.js and Laravel Sanctum (former Airlock).

We are going to create separate projects for the front end, and for the back end, that will interact with one another through a REST API.

Let's dive in!

Back end (Laravel)

Step #1

For Laravel installation instructions, visit an Official Documentation page.

Create a new Laravel project by running in the terminal

laravel new my-app
Enter fullscreen mode Exit fullscreen mode

or

composer create-project --prefer-dist laravel/laravel my-app
Enter fullscreen mode Exit fullscreen mode

I'm using Laravel Valet, which automatically allows us to access our site at the http://my-app.test domain.

On your machine, it will be accessible according to your local development environmental settings.

Step #2

Create a new database called my-app and set DB_DATABASE=my-app in .env file in your app's directory.

Step #3

Install Laravel Sanctum.

composer require laravel/sanctum
Enter fullscreen mode Exit fullscreen mode

Publish the Sanctum configuration and migration files using the vendor:publish Artisan command. The sanctum configuration file will be placed in your config directory:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Enter fullscreen mode Exit fullscreen mode

Run your database migrations to create a database table in which to store API tokens:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Add the Sanctum's middleware to your api middleware group within your app/Http/Kernel.php

../app/Http/Kernel.php

use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

...

    protected $middlewareGroups = [
        ...

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

    ...
],
Enter fullscreen mode Exit fullscreen mode

Step #4

To use tokens for users, we have to add HasApiTokens to the User model in app/User.php.

../app/User.php

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}
Enter fullscreen mode Exit fullscreen mode

Step #5

Let's create the seeder for the User model. We'll need that a bit later to test the login proccess.

php artisan make:seeder UsersTableSeeder
Enter fullscreen mode Exit fullscreen mode

Now let's insert

DB::table('users')->insert([
    'name' => 'John Doe',
    'email' => 'john@doe.com',
    'password' => Hash::make('password')
]);
Enter fullscreen mode Exit fullscreen mode

into the run() function in database/seeds/UsersTableSeeder.php

To seed users table with user, let's run:

php artisan db:seed --class=UsersTableSeeder
Enter fullscreen mode Exit fullscreen mode

So now we have a new user in our database called John Doe with email john@doe.com and password password.

Step #6

Let's create a /login route in the routes/api.php file:

../routes/api.php

use App\User;
use Illuminate\Support\Facades\Hash;

Route::post('/login', function (Request $request) {
    $data = $request->validate([
        'email' => 'required|email',
        'password' => 'required'
    ]);

    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        return response([
            'message' => ['These credentials do not match our records.']
        ], 404);
    }

    $token = $user->createToken('my-app-token')->plainTextToken;

    $response = [
        'user' => $user,
        'token' => $token
    ];

    return response($response, 201);
});
Enter fullscreen mode Exit fullscreen mode

Step #7

Let's send a POST request with email john@doe.com and password password as parameters to the http://my-app.test/api/login route. You can use the Postman or Insomnia software packages to accomplish this.

If everything is working well, we will receive a JSON object as response to our request:

{
    "user": {
        "id": 1,
        "name": "John Doe",
        "email": "john@doe.com",
        "email_verified_at": null,
        "created_at": null,
        "updated_at": null
    },
    "token": "AbQzDgXa..."
}
Enter fullscreen mode Exit fullscreen mode

Step #8

Next, we need to change some middleware. We do this in the /routes/api.php file by replacing auth:api with auth:sanctum:

../routes/api.php

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

Step #9

Before we continue to the front end, we have to setup cross-origin requests CORS handling.

../config/cors.php

    'paths' => ['api/*', 'login', 'logout'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['*'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => true,
Enter fullscreen mode Exit fullscreen mode
../.env

SANCTUM_STATEFUL_DOMAINS=127.0.0.1
Enter fullscreen mode Exit fullscreen mode

Front end (Vue.js)

We'll use Vuex for state management, Vue Router for routing and axios to make HTTP requests.

Step #1

We are going to use Vue CLI to create a new Vue project. If you are not familiar with this standard tooling for Vue.js development, please, read this guide.

In the directory we are using for our projects, let's run the following command:

vue create my-vue-app
Enter fullscreen mode Exit fullscreen mode

Choose to Manually select features and then opt for Router and Vuex

vue_create

After successfully creating the my-vue-app project, run the following commands:

cd my-vue-app
npm run serve
Enter fullscreen mode Exit fullscreen mode

Now our app should be available at the http://localhost:8080/ domain.

Step #2

Let's create a new file for a Login view.

..src/views/Login.vue

<template>
  <div>
    <h1>Login</h1>
    <form @submit.prevent="login">
      <input type="email" name="email" v-model="email">
      <input type="password" name="password" v-model="password">
      <button type="submit">Login</button>
    </form>
  </div>
</template>

<script>
export default {
  data () {
    return {
      email: '',
      password: ''
    }
  },

  methods: {
    login () {
      this.$store
        .dispatch('login', {
          email: this.email,
          password: this.password
        })
        .then(() => {
          this.$router.push({ name: 'About' })
        })
        .catch(err => {
          console.log(err)
        })
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

In the Vue Router, we have to implement a route for the Login view.

../src/router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue')
  }
]

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

export default router

Enter fullscreen mode Exit fullscreen mode

Now, if we navigate to http://localhost:8080/login in a browser, we can see a login page.

login

Step #3

We have to install axios in our frontend directory to make HTTP requests:

npm install axios
Enter fullscreen mode Exit fullscreen mode

Step #4

Let's implement some user authentication actions (login/logout) in Vuex.

../src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

axios.defaults.baseURL = 'http://app-backend.test/api'

export default new Vuex.Store({
  state: {
    user: null
  },

  mutations: {
    setUserData (state, userData) {
      state.user = userData
      localStorage.setItem('user', JSON.stringify(userData))
      axios.defaults.headers.common.Authorization = `Bearer ${userData.token}`
    },

    clearUserData () {
      localStorage.removeItem('user')
      location.reload()
    }
  },

  actions: {
    login ({ commit }, credentials) {
      return axios
        .post('/login', credentials)
        .then(({ data }) => {
          commit('setUserData', data)
        })
    },

    logout ({ commit }) {
      commit('clearUserData')
    }
  },

  getters : {
    isLogged: state => !!state.user
  }
})

Enter fullscreen mode Exit fullscreen mode

After successfully logging in, we are going to store some user data in the user variable and the localStorage.

Step #5

Let's define routes for authenticated and unauthenticated pages.

We can make an About page accessible for authenticated users only.

For this purpose let's add the meta field to the About route.

Let's use Vue Router's beforeEach method to check if the user is logged in. If the user is not authenticated, we'll redirect them back to the login page.

../src/router.index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    meta: {
      auth: true
    },
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue')
  }
]

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

router.beforeEach((to, from, next) => {
  const loggedIn = localStorage.getItem('user')

  if (to.matched.some(record => record.meta.auth) && !loggedIn) {
    next('/login')
    return
  }
  next()
})

export default router

Enter fullscreen mode Exit fullscreen mode

Step #6

What if the user refreshes a page? Should we ask him to log in again?

Of course not!

Let's add a created() method to the Vue instance to handle that scenario.

created () {
  const userInfo = localStorage.getItem('user')
  if (userInfo) {
    const userData = JSON.parse(userInfo)
    this.$store.commit('setUserData', userData)
  }
}
Enter fullscreen mode Exit fullscreen mode

Step #7

We also need to handle cases when a token is expired or if the user is unauthorized.

Let's do that in the created() method by using interceptors.

So our updated main.js file looks like this:

../src/main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  created () {
    const userInfo = localStorage.getItem('user')
    if (userInfo) {
      const userData = JSON.parse(userInfo)
      this.$store.commit('setUserData', userData)
    }
    axios.interceptors.response.use(
      response => response,
      error => {
        if (error.response.status === 401) {
          this.$store.dispatch('logout')
        }
        return Promise.reject(error)
      }
    )
  },
  render: h => h(App)
}).$mount('#app')

Enter fullscreen mode Exit fullscreen mode

Step #8

We haven't implemented a Logout feature yet. Let's do that in the App.vue file.

Also, let's show the About and Logout buttons only when a user is logged in.

../src/App.vue

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about" v-if="isLogged">About</router-link>
      <router-link to="/login" v-else>Login</router-link>
      <button type="button" @click="logout()" v-if="isLogged">
        Logout
      </button>
    </div>
    <router-view/>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters([
      'isLogged'
    ])
  },

  methods: {
    logout () {
      this.$store.dispatch('logout')
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Ok, our tutorial is done.

I hope you found this info helpful!

View source code
Front End
Back End

Latest comments (43)

Collapse
 
bodrosh profile image
Bodrosh

Thanks, great article!

Collapse
 
mrdt12 profile image
Dennis Tabaldo

greate article! thanks

Collapse
 
zynth17 profile image
Christopher Reeve • Edited

thankyou for this great article!
but anyway is it safe to safe the sanctum token on the localstorage?

Collapse
 
billjustin15 profile image
billjustin

This is helpful! I would like to ask, do i need to change SANCTUM_STATEFUL_DOMAINS value if host my app?

Collapse
 
romanpaprotsky profile image
Roman Paprotsky

img

Collapse
 
nicholascourage profile image
nicholascourage

Thanks for this! What should we use for SANCTUM_STATEFUL_DOMAINS=127.0.0.1 in a dockerised local dev environment?

Collapse
 
romanpaprotsky profile image
Roman Paprotsky

I don't have work experience with Laravel + Docker, sorry.

Collapse
 
nicholascourage profile image
nicholascourage

That's ok - thanks for coming back to me. I'll reply to myself and answer as and when I find out.

Collapse
 
nicholascourage profile image
nicholascourage • Edited

Thanks for this! What should we use for SANCTUM_STATEFUL_DOMAINS=127.0.0.1 in a dockerised local dev environment?

Collapse
 
gcj5219 profile image
gcj

Great Article, Currently I'm doing some coding practice with the laravel socialite package using postman for api request. Do you have a written article for this kind of log in using 3rd party API like google. facebook and twitter?

Collapse
 
dominiquebureau profile image
Dominique

Thanks for this tutorial. I had tried to do the same with React. And I had never managed to do it. VueJs is much simpler!

A little evolution please. Imagine an 'about2' route. If I try to access it without being logged in, the app sends me back to the login page. Perfect ! But then, how can I be redirected automatically to 'about2' and not to 'about'? I guess I must store the target route name in the store but after ?

thanks again for this tutorial.

Collapse
 
romanpaprotsky profile image
Roman Paprotsky

This Vue Screencasts video can be useful.

Collapse
 
romanpaprotsky profile image
Roman Paprotsky

Hi Dominique,

I guess it definitely make sense to store the target route name in the store and check it out in the login() method in the ..src/views/Login.vue view before this.$router.push({ name: 'About' })

Collapse
 
eugenevdm profile image
Eugene van der Merwe

I really love this article! I love the style of writing, and the way to code is presented. Furthermore the way the code is presented is very clear and concise. This is a great template to get to know a number of modern day Laravel/Vue.js concepts - APIs, Sanctum, Vuex, Vue routing. It will be my go to reference going forward. Thank you!

Collapse
 
romanpaprotsky profile image
Roman Paprotsky

Thank you for the kind words, Eugene!

Collapse
 
morris14 profile image
morris14 • Edited

Great post! I noticed you store the token in localstorage, is this safe? I always thought this was an insecure way of storing sensitive data seeing as the token is more or less the users username/password combo?

Collapse
 
onegeco profile image
OneGeco

Hi @moris14 it's really wrong storing sensitive data in LocalStorage meanwhile i haven't seen any article on a better way to go about this, please share if you have any.

Collapse
 
gweinxx profile image
gweinxx • Edited

(Based on my app)

U can verify token owner by comparing ip address, browser: version, device, custom cookies, etc

  1. You need to track users here
  2. Token hasOne Login (Ip address, device platform, browser name, browser version, etc)
  3. If you still need to verify the user, add password verification feature, for worse scenarios like:
  4. Token is not being used for (x) days
  5. Tracker result doesn't match token login's information (#2)
  6. Token exist, but custom cookies is expired / missing

With this feature, personally I can offer the user to see, what devices are they are logged in, logout from specific device / all devices, etc

If anyone got better idea, I would like to know and learn :D

  • Note: sorry for bad english