Setting up Laravel Sanctum (Airlock) for SPA authentication with Vue.js

March 29th, 2020

Laravel Sanctum (previously known as Laravel Airlock) is an official Laravel package to deal with both API token and SPA (Single Page Application) authentication.

We're focusing on SPA authentication using a simple Vue.js app. It's really important to note that this guide has nothing to do with issuing and using tokens to communicate with an API. Sanctum does that too, but it's not our focus.

If you're authenticating users on your SPA already, you're probably either using JWT (JSON Web Tokens) or Laravel Passport. I've always leaned towards using JWT authentication, but with the arrival of Sanctum I'm now exclusively using it when I whip up a new project. Because it is brilliant.

The main reason for my switch is twofold:

  1. It's an official Laravel package, so you're pretty much guaranteed it's going to effortlessly integrate with the framework.
  2. You don't need to worry about manually storing tokens on the client, as authentication is handled through cookies.

On that second point, let's talk a little about how Sanctum works.

Before we start blindly mashing away without an understanding of what's happening behind the scenes, let's run over how Sanctum works.

Sanctum uses Laravel's cookie-based session authentication to authenticate users from your client. Here's the flow.

  1. You request a CSRF cookie from Sanctum on the client, which allows you to make CSRF-protected requests to normal endpoints like /login.
  2. You make a request to the normal Laravel /login endpoint.
  3. Laravel issues a cookie holding the user's session.
  4. Any requests to your API now include this cookie, so your user is authenticated for the lifetime of that session.

And you don't have to do much to get this working. The hardest part is installing and configuring Sanctum.

We'll start with a fresh Laravel 7 installation so we don't complicate things. If you already have a project you're switching over to (particularly if you're using an older version of Laravel) there may be some differences here. My recommendation would be to set this up with a fresh Laravel 7 project first to get the hang of Sanctum, and then work out the specifics for your current project.

Let's go.

You probably already know how to do this, but let's create a fresh Laravel project, which I've called, imaginatively, testingsanctum.

composer create-project laravel/laravel testingsanctum

Instead of using Homestead, Valet or Docker, let's keep things simple and serve Laravel locally.

php artisan serve

Don't use 127.0.0.1, use localhost instead. This is really important later on when we configure our Sanctum domains and CORS origins.

Finally, make sure to switch over your database connection settings so we can use a database. I use Postgres locally, so here's mine:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=testingsanctum
DB_USERNAME=alexgarrettsmith
DB_PASSWORD=

Yup, we still need our standard authentication controllers, and with Laravel 7, these are tucked away in the laravel/ui package.

First get the laravel/ui package installed.

composer require laravel/ui

Then generate the authentication scaffolding (we're just using Bootstrap here).

php artisan ui bootstrap --auth

And finally compile assets, so we have some styling on our authentication pages.

npm install && npm run dev

We don't actually need this, but it helps if you still want to use standard web authentication for your project, and use Vue components in Laravel that make requests authenticated endpoints.

First, pull down the laravel/sanctum package.

composer require laravel/sanctum

Now publish the configuration files and migrations.

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

*Wait, migrations? *We only need these when using Sanctum for API token authentication. Feel free to delete the created migration if you don't want to add the table to your database. I like to keep it there anyway, just incase I want to use this feature of Sanctum later on.

Run your migrations.

php artisan migrate

Important, because this creates the users table, which we need for authentication.

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 our API can make use of session cookies, since that's how Sanctum authenticates when making requests.

Open up the config/sanctum.php file and take a look. It's crucial 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 for us, localhost is already in there, so we're good to go. You will need to change this when deploying to production, so adding SANCTUM_STATEFUL_DOMAINS to your .env file with a comma separated list of allowed domains is a great idea.

I tend to add this anyway, since it sets my .env file with a reference to what I should be changing in production.

SANCTUM_STATEFUL_DOMAINS=localhost

In .env, update your session driver to use something other than file. The cookie option will work fine for now.

SESSION_DRIVER=cookie

Laravel 7 ships with the fruitcake/laravel-cors package. Head over to your config/cors.php config file and update the paths to look like this:

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

Because we're potentially going to be making requests from another domain, we're making sure that as well as our API endpoints, we're also allowing cross-origin requests to /login and /logout, as well as the special /sanctum/csrf-cookie endpoint (more on this later).

You'll also want to set the supports_credentials option to true.

'supports_credentials' => true

At the moment, in routes/api.php, we have the auth:api middleware set for the example API route Laravel provides. This won't do, because when we eventually send a request from our client, we'll need Sanctum to pick up the session cookie and figure out if we're authenticated or not.

Update this route to use the auth:sanctum middleware instead.

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

We'll be hitting this endpoint later on to verify if our authentication process worked.

Do this through the web UI or on the command line with Tinker, whichever you prefer. If you're curious, here's the command to create a user with Tinker.

php artisan tinker
factory(App\User::class)->create(['name' => 'Alex Garrett-Smith', 'email' => 'alex@codecourse.com', 'password' => bcrypt('ilovecats')]);

And with all that done, we're just about ready to go. Let's recap first.

  1. We've installed Laravel, obviously.
  2. We have our normal authentication controllers pulled in from laravel/ui, because we're going to be making requests to them from our client.
  3. Sanctum is installed, configured, and we're allowing authenticated requests from localhost.
  4. We can make cross-origin requests to /login, /logout and /sanctum/csrf-cookie.

The best way to verify everything is working is to create a completely fresh Vue CLI project and make some requests. Again, you might already have a SPA ready to roll, but test this out with a completely fresh one and work out the specifics of your current implementation later.

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 testingsanctumclient

Below are the options you'll want to select. I'm pulling in Vuex because we want a nice way to keep track of the state of our authenticated user. We'll also need Vue Router, to create a separate sign in page.

For any subsequent prompts Vue CLI throws at you, choose whatever options you prefer.

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

Notice we're on localhost again. Our API and client domains match up so we shouldn't run into any issues.

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 new file, views/SignIn.vue with a simple sign in form.

<template>
  <form action="#">
    <div>
      <label for="email">Email address</label>
      <input type="text" name="email" id="email">
    </div>
    <div>
      <label for="password">Password</label>
      <input type="text" name="password" id="password">
    </div>
    <div>
      <button type="submit">
        Sign in
      </button>
    </div>
  </form>
</template>

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

Now add this page component to the router. I've removed the /about page here because we don't need it.

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

Vue.use(VueRouter)
    
const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/signin',
    name: 'SignIn',
    component: SignIn
  }
]
    
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

Head over to /signin in the browser and you'll see your beautifully crafted work.

We want to be able to show navigation options depending on whether the user is signed in or not, and also include their name if they are signed in. We'll pull user details from the API once we're authenticated.

Open up App.vue and add something like this.

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/signin">Sign in</router-link> |
      <router-link to="/account">Alex Garrett-Smith</router-link> |
      <a href="#">Sign out</a>
    </div>
    <router-view/>
  </div>
</template>

We'll dynamically update this template to show our name and sign out link once we're authenticated.

I'll prefix this with, *why are we using Vuex? *Well, since we want to hold an overall authenticated 'state' in our client, using a state management library like Vuex makes sense here. It'll also allow us to easily check within any component if we're authenticated or not (e.g. our navigation).

First, create a store/auth.js file with the following.

import axios from 'axios'

export default {
  namespaced: true,

  state: {
    authenticated: false,
    user: null
  },

  getters: {
    authenticated (state) {
      return state.authenticated
    },

    user (state) {
      return state.user
    },
  },

  mutations: {
    SET_AUTHENTICATED (state, value) {
      state.authenticated = value
    },

    SET_USER (state, value) {
      state.user = value
    }
  },

  actions: {
    //
  }
}

If you've used Vuex before, this should be pretty easy to understand. If not, we've just created a Vuex module specifically for auth functionality, so it's neatly tucked away.

The state property holds whether we're authenticated or not, and holds the user details we'll be fetching once authenticated.

Our getters return to us that state.

Our mutations update our state. For example, once we're successfully authenticated, we'll commit a mutation to set authenticated to true and commit another mutation to set the user's details.

Now add the auth module to Vuex in store/index.js.

import Vue from 'vue'
import Vuex from 'vuex'
import auth from './auth'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    auth
  }
})

If you're using Vue devtools at this point, you should see the reflected state in the Vue tab of your browser console.

Let's update the store/auth.js file and add some actions. Don't forget to import axios at the top!

import axios from 'axios'

export default {
  // ...

  actions: {
    async signIn ({ dispatch }, credentials) {
      await axios.get('/sanctum/csrf-cookie')
      await axios.post('/login', credentials)

      return dispatch('me')
    },

    async signOut ({ dispatch }) {
      await axios.post('/logout')

      return dispatch('me')
    },

    me ({ commit }) {
      return axios.get('/api/user').then((response) => {
        commit('SET_AUTHENTICATED', true)
        commit('SET_USER', response.data)
      }).catch(() => {
        commit('SET_AUTHENTICATED', false)
        commit('SET_USER', null)
      })
    }
  }
}

Whoa, what's happening here?

The signIn action first makes a request to /sanctum/csrf-cookie. We added this to our CORS paths earlier, remember? Making a GET request to this endpoint asks our client to set a CSRF cookie, so further requests to our API include a CSRF token. This is really important, because we don't want to disableCSRF checks on any normal endpoints like /login. That's it.

Following on from this, the signIn action makes a normal request to /login with the credentials we provide.

At this point of dispatching this action, we should be totally authenticated. By sending a request to /login as normal, our client will set an authenticated session cookie for that user. This means any further requests to authenticated API endpoints should work. Magic!

Once that's all done, we dispatch the me action, which makes a request to the /api/user route. Now we have that session cookie set, this should successfully return to us our authenticated user's details, which we set in our state, along with a flag telling us we're authenticated.

Finally, if we dispatch the me action and this fails, we revert back to an unauthenticated state.

Before we make these requests, we'll need to set a base URL for our API (notice these are not included in the requests we have right now) and also enable the withCredentials option.

Hop on over to main.js and add those.

axios.defaults.withCredentials = true
axios.defaults.baseURL = 'http://localhost:8000/'

Psst, since we're setting defaults for axios, you'll need to make sure you import it at to top of main.js.

import axios from 'axios'

The withCredentials option is really important here. This instructs axios to automatically send our authentication cookie along with every request.

Hopefully you get what's going on in our Vuex store. Let's hook this up so we can actually enter some credentials and get authenticated.

Update your views/SignIn.vue component with the following.

<template>
  <form action="#" @submit.prevent="submit">
    <div>
      <label for="email">Email address</label>
      <input type="text" name="email" id="email" v-model="form.email">
    </div>
    <div>
      <label for="password">Password</label>
      <input type="text" name="password" id="password" v-model="form.password">
    </div>
    <div>
      <button type="submit">
        Sign in
      </button>
    </div>
  </form>
</template>

<script>
  import axios from 'axios'
  import { mapActions } from 'vuex'

  export default {
    name: 'SignIn',

    data () {
      return {
        form: {
          email: '',
          password: '',
        }
      }
    },

    methods: {
      ...mapActions({
        signIn: 'auth/signIn'
      }),

      async submit () {
        await this.signIn(this.form)

        this.$router.replace({ name: 'home' })
      }
    }
  }
</script>

This hooks up our email and password fields to the component data, and then adds a submit event handler to the overall form. Once invoked, our form handler dispatches the signIn action from our store and does everything we spoke about in the last section.

You should now be able to head over to the /signin page and give it a try with the user you created earlier. Give it a whirl!

If all goes well, you should see the following things.

Laravel's *laravel_session *cookie and the *XSRF-TOKEN *cookie.

Your Vuex state updated to reflect that we're signed in, along with the user's details (you might need to click 'load state' in Vue devtools to see this).

And check your Vue devtools. It now appears you're unauthenticated, but you're not. Our session cookie is still set, so any further requests we make to our API will be successful.

To get around losing our state, let's make sure that we're always requesting an authentication check when our app first runs.

Update main.js to dispatch the me Vuex action before the Vue app is created.

store.dispatch('auth/me').then(() => {
  new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount('#app')
})

*Now *refresh the page and check your Vue devtools (remember, you might need to click 'load state' again). Your state should show you're authenticated.

It's time to update the navigation to reflect this.

Head over to App.vue. Let's update the navigation links based on our authenticated state, and also output the name of the currently authenticated user.

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <template v-if="!authenticated">
        <router-link to="/signin">Sign in</router-link> |
      </template>
      <template v-else>
        <router-link to="/account">{{ user.name }}</router-link> |
        <a href="#">Sign out</a>
      </template>
    </div>
    <router-view/>
  </div>
</template>

<script>
  import { mapGetters } from 'vuex'

  export default {
    computed: {
      ...mapGetters({
        authenticated: 'auth/authenticated',
        user: 'auth/user',
      })
    }
  }
</script>

Notice we're using mapGetters here, much like mapActions we saw before.

If you're signed in, you should see this change reflected in the navigation items, and it should be displaying the name from the API.

Because we're already authenticated, and Laravel knows who we are, making a request to sign out is pretty easy. This process will invalidate our session, so the cookie we're automatically sending to our API will no longer be valid.

Again, in App.vue.

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <template v-if="!authenticated">
        <router-link to="/signin">Sign in</router-link> |
      </template>
      <template v-else>
        <router-link to="/account">{{ user.name }}</router-link> |
        <a href="#" @click.prevent="signOut">Sign out</a>
      </template>
    </div>
    <router-view/>
  </div>
</template>

<script>
  import { mapGetters, mapActions } from 'vuex'

  export default {
    computed: {
      ...mapGetters({
        authenticated: 'auth/authenticated',
        user: 'auth/user',
      })
    },

    methods: {
      ...mapActions({
        signOutAction: 'auth/signOut'
      }),

      async signOut () {
        await this.signOutAction()

        this.$router.replace({ name: 'home' })
      }
    }
  }
</script>

Here, we've pulled in the ability to map our Vuex actions, added the signOut action we created earlier, hooked our sign out link up to the signOut method and then we're redirecting back home if successful.

Give it a click and you should be signed out.

Naturally, the Laravel documentation for Sanctum gives you a good reference for installing and setting up.

Prefer screencasts? I've published two courses on using Sanctum with Nuxt and Vue.js.

Laravel Sanctum with Nuxt

Laravel Sanctum with Vue

Thanks for reading! If you found this article helpful, you might enjoy our practical screencasts too.
Author
Alex Garrett-Smith
Share :

Comments

No comments, yet. Be the first to leave a comment.