WordPress Login with Vue JS (Authentication API)

Headless WordPress is more powerful than most people thought. But integrating WP Login Authentication with Vue is quite tricky. Learn more in this tutorial.

Update Nov 2022: This tutorial uses Vue 2. If you prefer Vue 3, check out my WP Vue Boilerplate that comes with Registration and ForgotPassword.

Headless WordPress is more powerful than most people thought. The API is easy to customize and most people are already familiar with the admin UI.

One thing I had trouble with is integrating Login authentication with Vue. So I will share what I have learned here:

TABLE OF CONTENT

  1. Vue Installation
  2. WP Installation
  3. Project Setup
  4. Router
  5. Login UI
  6. Store
  7. Environment Variable
  8. Finishing
  9. Try it Out

TLDR: Read the finished code from my Github.

1. Vue Installation

Let’s start by generating the boilerplate files. If you don’t have Vue CLI installed, run this:

npm install -g @vue/cli

Then run this command to create a new project in “/my-app” folder.

vue create my-app

This tutorial uses Vue 2. But feel free to use Vue 3 if you know which part to change:

After the boilerplate is generated, modify package.json to add axios, vue-router, vuex, node-sass, and sass-loader:

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vue-cli-service serve",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "axios": "^0.21.1",
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.5.2",
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.13",
    "@vue/cli-plugin-eslint": "~4.5.13",
    "@vue/cli-service": "~4.5.13",
    "babel-eslint": "^10.1.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^6.2.2",
    "vue-template-compiler": "^2.6.11",
    "node-sass": "^4.12.0",
    "sass-loader": "^8.0.2"
  },
  "babel": {
    "presets": [
      "@vue/cli-plugin-babel/preset"
    ]
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

Finally, run this command to install all packages:

npm install

2. WP Installation

I assume you have an existing WordPress installation, either a local or live one. If you don’t, create localhost with LOCAL.

Install this plugin in your WordPress: JWT Authentication.

Then add this to your wp-config:

define('JWT_AUTH_CORS_ENABLE', true);
define('JWT_AUTH_SECRET_KEY', 'insert-random-hash');

You can get the random hash by picking any from https://api.wordpress.org/secret-key/1.1/salt/

3. Project Setup

We will create these 7 new files:

  • src/router.js – handles URL routing and permission.
  • src/UserLogin.vue – Login page.
  • src/components/Loading.vue – Loading animation.
  • src/store.js – handles global state like “isLoggedIn” or “isAdmin”.
  • src/Home.vue – Plain homepage just to see if we successfully logged in.
  • .env.development and .env.production – Environment variable.

Now, your project structure looks like this:

my-app/
├── public/...
├── src/
│   ├── assets/...
│   ├── components/
│   │   ├── Loading.vue
│   ├── App.vue
│   ├── Home.vue
│   ├── main.js
│   ├── router.js
│   ├── store.js
│   └── UserLogin.vue
├── .env.development
├── .env.production
├── .gitignore
├── README.md
└── package.json

4. Router

There will be two primary functions of router.js:

  • Manage URL – going to /login will render UserLogin.vue.
  • Manage Permission – If we’re not logged in, we can’t access Home.

This is the code: (all of the important parts are commented on)

import Vue from 'vue';
import VueRouter from 'vue-router';
import store from './store';

import Home from './Home.vue';
import UserLogin from './UserLogin.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
    meta: {
      title: 'Home',
    },
  },
  {
    path: '/login',
    name: 'UserLogin',
    component: UserLogin,
    meta: {
      title: 'Login',
      noAuthRequired: true,
    },
  },
];

const router = new VueRouter({
  mode: 'history',
  base: '/',
  routes,
  scrollBehavior() {
    document.getElementById('app').scrollIntoView({ behavior: 'smooth' });
  },
});

// Set SEO metatag
router.beforeEach((to, from, next) => {
  document.title = `${to.meta.title} | My App`;
  next();
});

// Logged-in state check
router.beforeEach((to, from, next) => {
  // redirect to Login page if auth required but not logged in
  if (!to.meta.noAuthRequired && !store.state.isLoggedIn) {
    next({
      name: 'UserLogin',
      query: {
        redirectTo: to.name,
        message: 'Please login to visit that page',
      },
    });
  }

  next();
});

export default router;

5. Login UI

We will create a simple interface with submit listener.

Here is the UserLogin.vue file:

<template>
  <div class="form-wrapper">
    <!-- Error message -->
    <div v-if="message" class="form-message is-error-message">
      {{ message }}
    </div>
    <!-- When redirected from secured page -->
    <div v-else-if="$route.query.message" class="form-message">
      {{ $route.query.message }}
    </div>

    <!-- Main form -->
    <form class="form" @submit.prevent="login">
      <label>
        <span>Username</span>
        <input
          v-model.trim="username"
          type="text"
          placeholder="username or email"
        >
      </label>
      <label>
        <span>Password</span>
        <input
          v-model.trim="password"
          type="password"
        >
      </label>
      <button type="submit">
        Log In
      </button>
    </form>

    <Loading v-if="isLoading" />
  </div>
</template>

<script>
import Loading from './components/Loading.vue';

export default {
  name: 'UserLogin',
  components: {
    Loading,
  },
  data() {
    return {
      username: '',
      password: '',

      message: '',
      isLoading: false,
    };
  },

  methods: {
    async login() {
      this.message = '';
      this.isLoading = true;

      try {
        // Call the login function in store.js
        await this.$store.dispatch('login', {
          username: this.username,
          password: this.password,
        });

        this.isLoading = true;

        // if redirected to login from secured page, redirect back
        if (this.$route.query.redirectTo) {
          this.$router.push({ name: this.$route.query.redirectTo });
        }
        // else redirect to Home
        else {
          this.$router.push({ name: 'Home' });
        }
      } catch (error) {
        this.isLoading = false;
        this.message = 'Email or password is wrong';
      }
    }
  }
}
</script>

<style lang="sass">
.form-wrapper
  max-width: 360px
  margin: 3rem auto

.form-message
  padding: 0.25rem
  margin-bottom: 1rem
  box-shadow: 0 0 10px 0 rgba(black, .1)
  
  &.is-error-message
    color: red

.form
  padding: 1rem
  border: 1px solid gray

  label
    display: block
    margin-bottom: 1rem

  input
    width: 100%
    padding: 0.5rem

  span
    display: block
    text-transform: uppercase
    font-size: smaller

  button
    padding: 0.5rem 1rem
    background-color: green
    color: white
</style>

Notice the $store.dispatch() in the login listener? This is calling the function inside store.js. We will implement that later.

We also added Loading animation by importing Loading.vue. Here’s the code:

<template>
  <div class="loading">
    <span />
  </div>
</template>

<script>
export default {
  name: 'Loading',
};
</script>

<style lang="sass" scoped>
.loading
  top: 0
  left: 0
  z-index: 101
  height: 100%
  width: 100%
  background-color: rgba(black, .5)
  position: fixed
  display: flex
  justify-content: center
  align-items: center

  @keyframes spin
    to
      transform: rotateZ(360deg)

  span
    display: block
    width: 60px
    height: 60px
    margin: 0 auto
    border: 3px solid transparent
    border-top-color: #fff
    border-bottom-color: #fff
    border-radius: 50%
    animation: spin ease 1000ms infinite
</style>

6. Store

Store is a Vuex feature to manage global variables and functions. It consisted of three parts:

  • state – The global variables.
  • mutations – Functions to modify the global variables.
  • actions – The global functions.

Here’s our store.js file:

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

Vue.use(Vuex);

const mutations = {
  cacheUser(state, { token, email, displayName }) {
    state.isLoggedIn = true;
    state.userToken = token;
    state.userEmail = email;
    state.userDisplayName = displayName;
  },

  deleteUserCache(state) {
    state.isLoggedIn = false;
    state.userToken = '';
    state.userEmail = '';
    state.userDisplayName = '';
  },
};

const actions = {
  async login({ commit }, payload) {
    const response = await axios.post(`${process.env.VUE_APP_API_URL}/jwt-auth/v1/token`, {
      username: payload.username,
      password: payload.password,
    });

    const data = response.data;

    localStorage.setItem('isLoggedIn', true);
    localStorage.setItem('token', data.token);
    localStorage.setItem('displayName', data.user_display_name);
    localStorage.setItem('email', data.user_email);

    // call cacheUser() from mutations
    await commit('cacheUser', {
      token: data.token,
      email: data.user_email,
      displayName: data.user_display_name,
    });
  },

  async logout({ commit }) {
    localStorage.removeItem('isLoggedIn');
    localStorage.removeItem('token');
    localStorage.removeItem('email');
    localStorage.removeItem('displayName');

    commit('deleteUserCache');
  },

  /**
   * Check if user if logged in
   */
  async checkLoginState({ commit }) {
    const token = localStorage.getItem('token');

    // if no token, empty the loggedIn cache
    if (!token) {
      await commit('deleteUserCache');
      return false;
    }

    // if has token, check if it's still valid
    try {
      await axios.post(
        `${process.env.VUE_APP_API_URL}/jwt-auth/v1/token/validate`,
        {},
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
      );
      
      // if still valid, cache it again
      await commit('cacheUser', {
        token,
        email: localStorage.getItem('email'),
        displayName: localStorage.getItem('displayName'),
      });

      return true;
    } catch (error) {
      localStorage.setItem('token', '');
      return false;
    }
  },
}


const store = {
  // This is global data, use mutations and actions to change this value.
  state: {
    isAdmin: false,
    isLoggedIn: localStorage.getItem('isLoggedIn') === 'true',
    userToken: localStorage.getItem('token') || '',
    userEmail: localStorage.getItem('email') || '',
    userDisplayName: localStorage.getItem('displayName') || '',
  },

  mutations,
  actions,
  modules: {
  },
};

export default new Vuex.Store(store);

7. Environment Variable

Check out the login() function above. The API call is using environment variable for the domain name.

You set this in .env.development and .env.production:

VUE_APP_API_URL=http://mysite.test/wp-json
VUE_APP_API_URL=http://mysite.com/wp-json

Note: The variable name has to be prefixed with VUE_APP.

8. Finishing

We are left with 3 more files to customize:

  • Home.vue – App dashboard. But for this tutorial, we only put sample text.
  • App.vue – Contains site layout like Header and Footer.
  • main.js – Entry point

Here’s Home.vue: (nothing special, just plain text)

<template>
  <div class="page">
    <h1>Hello World</h1>
    <p>
      Lorem, ipsum dolor sit amet consectetur adipisicing elit. Corporis nisi aliquid sunt culpa quia dolores porro repellat a magni aliquam, quo consectetur vero labore minus accusantium ducimus!
    </p>
    <p>
      Ratione consectetur voluptatibus dolorum, facilis rerum maxime architecto magni? Corporis fuga necessitatibus qui mollitia! Nesciunt eligendi doloribus eum quos sapiente officia ex architecto?
    </p>
  </div>
</template>

<script>
export default {
  name: 'Home',
  data() {
    return {};
  },
}
</script>

<style lang="sass">
.page
  max-width: 1000px
  margin: 2rem auto
</style>

App.vue contains Header and Footer. The header has a logout link, so we will implement that:

<template>
  <div id="app">
    <header class="main-header">
      <router-link :to="{ name: 'Home' }">
        <img alt="Logo" src="./assets/logo.png">
      </router-link>

      <nav v-if="$store.state.isLoggedIn">
        <!-- Show menu navigation -->

        <a href="#logout" @click.prevent="logout">
          Logout
        </a>
      </nav>
    </header>

    <router-view />

    <footer class="main-footer">
      © Copyright My App - All rights reserved
    </footer>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {};
  },
  // check if auth token is still valid
  async created() {
    // only check valid if coming from pages that require auth
    if (this.$route.meta.noAuthRequired) {
      return;
    }

    const isValid = await this.$store.dispatch('checkLoginState');

    if (!isValid) {
      this.$router.push({
        name: 'UserLogin',
        query: {
          redirectTo: this.$route.name,
        },
      });
    }
  },

  methods: {
    async logout() {
      await this.$store.dispatch('logout');
      this.$router.push({ name: 'UserLogin' });
    }
  }
}
</script>

<style lang="sass">
*,
*::before,
*::after
  box-sizing: border-box

#app
  font-family: Avenir, Helvetica, Arial, sans-serif
  -webkit-font-smoothing: antialiased
  -moz-osx-font-smoothing: grayscale
  color: #2c3e50
  margin-top: 60px

.main-header
  display: flex
  justify-content: center
  max-width: 1000px
  margin: 0 auto 2rem
  padding: 0.25rem 0
  border-bottom: 1px solid

  img
    max-height: 60px

  nav
    margin-left: auto
  
.main-footer
  max-width: 1000px
  margin: 3rem auto 0
  padding: 0.25rem 0
  border-top: 1px solid
  text-align: center

  font-size: smaller
</style>

Finally, main.js is your entry point. We need to import our router and store implementation here:

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

Vue.config.productionTip = false;

let app;
if (!app) {
  new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount('#app');
}

9. Try it Out

Make sure the JWT plugin is activated, constants in wp-config are set, and the environment variable in your Vue app is correct.

Then run this command to launch the Vue server:

npm run dev

Open the localhost URL as shown in the command result:

Since you’re not logged in, you will be redirected to the Login page:

After logging in, you will be redirected back to Home:

Conclusion

I was planning to add Register and Forgot Password functions. But the tutorial is already very long, so I will keep it for the future.

Also if the tutorial above isn’t working, you can cross-check it with our finished code on Github. We might miss something while writing the blog.

Thank you for reading, feel free to ask any question in the comment below.

Default image
Henner Setyono
A web developer who mainly build custom theme for WordPress since 2013. Young enough to never had to deal with using Table as layout.
Leave a Reply

6 Comments

  1. Thank you Henner, I have been looking for a working approach! I pulled your finished code from github. The login worked just fine.

    But the posts don't load. I get a 401 (unauthorized) executing the wpApi.get('/posts?_embed'). Any ideas?

    best regards Martin

    • Hi, have you changed the environment variable at .env.development?

      • Yes, I have. I couldn't have logged in with my credentials successfully otherwise, I believe.

        • Hi Martin, I just checked my repo and everything working as intended. Maybe you're using older WP version that doesn't support ?_embed parameter?

          Try going directly to http://yoursite.test/wp-json/wp/v2/posts?_embed and see whether it returns the correct API

          • Hi Henner, yes, when I'm logged in my wp installation I get an Api response.

            I added a console.log() in your helpers.js:

            // If logged in, add Authentication Header to the API call const token = localStorage.getItem('token'); console.log("token from my wp: " + token) if (token) { const addTokenHeader = (config) => { config.headers.Authorization = token ? Bearer ${token} : ''; return config; };

            => The returened token has a value of 'null'

            What am I missing here?

  2. Thank you friend ! You saved my life !