Authentication Essentials in Vue

User-specific data requires authentication in nearly every web app. For Vue developers, understanding how to implement this properly comes down to three core tools working together: Axios for API communication, Vuex for centralized state management, and Vue Router for protecting routes. Authentication verifies a user's identity — typically through email/username and password — and issues a token that grants access to protected resources. Without it, unauthorized users could access private data belonging to other users.

Project Setup and Dependencies

This tutorial walks through building a simple blog site using a hosted API. The API documentation shows which endpoints are restricted — marked with a lock icon — and which are open. The /register and /login endpoints are unrestricted, while requests to protected endpoints from unauthenticated users should return a 401 status error. After a successful login, the app receives an access token along with user data. This token must be stored securely — never in local storage — and attached to future request headers so the backend can verify each call to restricted endpoints.

Start by generating a new project with the Vue CLI, then install the required packages: vue-router, vuex, and axios. The project should now render in the browser as expected.

Configuring Vuex and Axios

A short refresher on these two libraries: Axios handles HTTP requests from the browser to the API, while Vuex is a centralized store for application state, with defined rules for how that state can mutate. The approach here uses Axios inside Vuex actions to send GET and POST requests, passing responses to mutations that update the store. One caveat: Vuex state resets on page refresh, so this tutorial also integrates vuex-persistedstate to retain data between reloads.

Create a store folder inside src, with a modules subfolder and an index.js file (unless the CLI already created these). The store imports Vuex and pulls in an auth module from modules. Modules group related state, actions, mutations, and getters together for better organization.

Axios Defaults

The main.js file needs updates to import both the store and Axios. Two critical Axios configurations are set here:

  • axios.defaults.baseURL — points to the API, so actions can reference endpoints like /register and /login without repeating the full URL.
  • axios.defaults.withCredentials = true — ensures Axios includes credentials (authorization headers, TLS certificates, or cookies) with each request, since cookies are not passed by default.

The Auth Module

Inside store/modules, create auth.js. This file contains the complete authentication logic.

State

The state object defines default values for user data and posts, both initialized to null. These values change as users log in, create posts, or log out.

Actions

Actions commit mutations to change state or dispatch other actions. The Register action receives form data, sends it to the /register endpoint, stores the response, then dispatches the form's username and password to the login action. This logs the user in immediately after signup, redirecting them to the /posts page.

The Login action is where authentication truly happens. It receives a User object — a FormData instance holding the username and password — makes a POST request to the /login endpoint, and finally commits the username to the setUser mutation.

The CreatePost action handles posting content to the /post endpoint, then dispatches GetPosts so the user sees their new post right away. The GetPosts action fetches posts from the /posts endpoint via a GET request and commits the setPosts mutation.

Finally, the LogOut action clears the user from browser cache by committing a logout mutation.

Mutations and Getters

Every mutation accepts the current state plus a value from the committing action — except Logout, which resets all variables back to null. Getters provide a clean way to read state across components. Two useful getters here: isAuthenticatated returns true or false based on whether state.user is defined, while StatePosts and StateUser directly return their corresponding state values.

The full auth.js file structure should match what's available in the companion GitHub repository.

Building the Views and Navigation

Start by cleaning up the starter scaffolding. Remove HelloWorld.vue from src/components and add NavBar.vue, a navigation component that links to the app's routed pages.

The NavBar uses v-if="isLoggedIn" to conditionally show the Logout link for authenticated users while hiding the Register and Login links. Its logout method, callable only when signed in, dispatches the LogOut action and routes the user back to the login page.

<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/posts">Posts</router-link> |
    <span v-if="isLoggedIn">
      <a @click="logout">Logout</a>
    </span>
    <span v-else>
      <router-link to="/register">Register</router-link> |
      <router-link to="/login">Login</router-link>
    </span>
  </div>
</template>
<script>
export default {
  name: 'NavBar',
  computed : {
      isLoggedIn : function(){ return this.$store.getters.isAuthenticated}
    },
    methods: {
      async logout (){
        await this.$store.dispatch('LogOut')
        this.$router.push('/login')
      }
    },
}
</script>
<style>
#nav {
  padding: 30px;
}
#nav a {
  font-weight: bold;
  color: #2c3e50;
}
a:hover {
  cursor: pointer;
}
#nav a.router-link-exact-active {
  color: #42b983;
}
</style>

Next, rewrite App.vue to import and render the NavBar above the <router-view />.

<template>
  <div id="app">
    <NavBar />
    <router-view/>
  </div>
</template>
<script>
// @ is an alias to /src
import NavBar from '@/components/NavBar.vue'
export default {
  components: {
    NavBar
  }
}
</script>
<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
</style>

The remaining page components live in the views folder. Delete the default About.vue and create Home.vue, Register.vue, Login.vue, and Posts.vue. The Home.vue component is a simple welcome page for visitors.

<template>
  <div class="home">
  <p>Heyyyyyy welcome to our blog, check out our posts</p>
  </div>
</template>
<script>

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

Registration and Login Forms

The registration view collects the data the API's /register endpoint expects — username, full_name, and password — and submits it to the store.

<template>
  <div class="register">
      <div>
          <form @submit.prevent="submit">
            <div>
              <label for="username">Username:</label>
              <input type="text" name="username" v-model="form.username">
            </div>
            <div>
              <label for="full_name">Full Name:</label>
              <input type="text" name="full_name" v-model="form.full_name">
            </div>
            <div>
              <label for="password">Password:</label>
              <input type="password" name="password" v-model="form.password">
            </div>
            <button type="submit"> Submit</button>
          </form>
      </div>
      <p v-if="showError" id="error">Username already exists</p>
  </div>
</template>

Inside the script section, bring in mapActions from Vuex so the component can invoke actions directly. The data() function holds the form state plus a showError boolean. The submit method calls this.Register with the form payload; on success it routes via this.$router to the login page, and on failure it flips showError to true.

<script>
import { mapActions } from "vuex";
export default {
  name: "Register",
  components: {},
  data() {
    return {
      form: {
        username: "",
        full_name: "",
        password: "",
      },
      showError: false
    };
  },
  methods: {
    ...mapActions(["Register"]),
    async submit() {
      try {
        await this.Register(this.form);
        this.$router.push("/posts");
        this.showError = false
      } catch (error) {
        this.showError = true
      }
    },
  },
};
</script>
<style scoped>
* {
  box-sizing: border-box;
}
label {
  padding: 12px 12px 12px 0;
  display: inline-block;
}
button[type=submit] {
  background-color: #4CAF50;
  color: white;
  padding: 12px 20px;
  cursor: pointer;
  border-radius:30px;
}
button[type=submit]:hover {
  background-color: #45a049;
}
input {
  margin: 5px;
  box-shadow:0 0 15px 4px rgba(0,0,0,0.06);
  padding:10px;
  border-radius:30px;
}
#error {
  color: red;
}
</style>

The Login.vue view works on the same pattern. It takes the user's username and password, passes them to the API for authentication, and sends authenticated users to the protected Posts page. Any error is caught and surfaces via showError.

<template>
  <div class="login">
    <div>
      <form @submit.prevent="submit">
        <div>
          <label for="username">Username:</label>
          <input type="text" name="username" v-model="form.username" />
        </div>
        <div>
          <label for="password">Password:</label>
          <input type="password" name="password" v-model="form.password" />
        </div>
        <button type="submit">Submit</button>
      </form>
      <p v-if="showError" id="error">Username or Password is incorrect</p>
    </div>
  </div>
</template>
<script>
import { mapActions } from "vuex";
export default {
  name: "Login",
  components: {},
  data() {
    return {
      form: {
        username: "",
        password: "",
      },
      showError: false
    };
  },
  methods: {
    ...mapActions(["LogIn"]),
    async submit() {
      const User = new FormData();
      User.append("username", this.form.username);
      User.append("password", this.form.password);
      try {
          await this.LogIn(User);
          this.$router.push("/posts");
          this.showError = false
      } catch (error) {
        this.showError = true
      }
    },
  },
};
</script>
<style scoped>
* {
  box-sizing: border-box;
}
label {
  padding: 12px 12px 12px 0;
  display: inline-block;
}
button[type=submit] {
  background-color: #4CAF50;
  color: white;
  padding: 12px 20px;
  cursor: pointer;
  border-radius:30px;
}
button[type=submit]:hover {
  background-color: #45a049;
}
input {
  margin: 5px;
  box-shadow:0 0 15px 4px rgba(0,0,0,0.06);
  padding:10px;
  border-radius:30px;
}
#error {
  color: red;
}
</style>

The Protected Posts Page

Posts.vue is the restricted area that requires a valid session. It exposes a form for creating posts and lists existing posts pulled from the API. When no posts exist, a fallback message is shown instead of an empty list.

<template>
  <div class="posts">
      <div v-if="User">
        <p>Hi {{User}}</p>
      </div>
      <div>
          <form @submit.prevent="submit">
            <div>
              <label for="title">Title:</label>
              <input type="text" name="title" v-model="form.title">
            </div>
            <div>
              <textarea name="write_up" v-model="form.write_up" placeholder="Write up..."></textarea>
            </div>
            <button type="submit"> Submit</button>
          </form>
      </div>
      <div class="posts" v-if="Posts">
        <ul>
          <li v-for="post in Posts" :key="post.id">
            <div id="post-div">
              <p>{{post.title}}</p>
              <p>{{post.write_up}}</p>
              <p>Written By: {{post.author.username}}</p>
            </div>
          </li>
        </ul>
      </div>
      <div v-else>
        Oh no!!! We have no posts
      </div>
  </div>
</template>

The component maps StateUser and StatePosts from the store via mapGetters. The form object holds title and write_up, both initialized to empty strings. Submitting a post triggers this.CreatePost with the form, while the created lifecycle hook calls this.GetPosts to load data on mount.

<script>
import { mapGetters, mapActions } from "vuex";
export default {
  name: 'Posts',
  components: {
    
  },
  data() {
    return {
      form: {
        title: '',
        write_up: '',
      }
    };
  },
  created: function () {
    // a function to call getposts action
    this.GetPosts()
  },
  computed: {
    ...mapGetters({Posts: "StatePosts", User: "StateUser"}),
  },
  methods: {
    ...mapActions(["CreatePost", "GetPosts"]),
    async submit() {
      try {
        await this.CreatePost(this.form);
      } catch (error) {
        throw "Sorry you can't make a post now!"
      }
    },  
  }
};
</script>
<style scoped>
* {
  box-sizing: border-box;
}
label {
  padding: 12px 12px 12px 0;
  display: inline-block;
}
button[type=submit] {
  background-color: #4CAF50;
  color: white;
  padding: 12px 20px;
  cursor: pointer;
  border-radius:30px;
  margin: 10px;
}
button[type=submit]:hover {
  background-color: #45a049;
}
input {
  width:60%;
  margin: 15px;
  border: 0;
  box-shadow:0 0 15px 4px rgba(0,0,0,0.06);
  padding:10px;
  border-radius:30px;
}
textarea {
  width:75%;
  resize: vertical;
  padding:15px;
  border-radius:15px;
  border:0;
  box-shadow:0 0 15px 4px rgba(0,0,0,0.06);
  height:150px;
  margin: 15px;
}
ul {
  list-style: none;
}
#post-div {
  border: 3px solid #000;
  width: 500px;
  margin: auto;
  margin-bottom: 5px;;
}
</style>

Route Guards and Token Expiry

Wire up the router in router/index.js by importing the views and assigning each one a path. The posts route carries a meta: { requiresAuth: true } flag.

import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '../store';
import Home from '../views/Home.vue'
import Register from '../views/Register'
import Login from '../views/Login'
import Posts from '../views/Posts'

Vue.use(VueRouter)
const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/register',
    name: "Register",
    component: Register,
    meta: { guest: true },
  },
  {
    path: '/login',
    name: "Login",
    component: Login,
    meta: { guest: true },
  },
  {
    path: '/posts',
    name: Posts,
    component: Posts,
    meta: {requiresAuth: true},
  }
]
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

To protect routes, add a router.beforeEach navigation guard. When a target route has the requiresAuth meta key, the guard checks the Vuex store for a token; missing tokens redirect the user to login.

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})
router.beforeEach((to, from, next) => {
  if(to.matched.some(record => record.meta.requiresAuth)) {
    if (store.getters.isAuthenticated) {
      next()
      return
    }
    next('/login')
  } else {
    next()
  }
})

export default router

Conversely, the /register and /login routes use meta: { guest: true }. A second guard blocks previously authenticated users from reaching these guest-only pages.

router.beforeEach((to, from, next) => {
  if (to.matched.some((record) => record.meta.guest)) {
    if (store.getters.isAuthenticated) {
      next("/posts");
      return;
    }
    next();
  } else {
    next();
  }
});
import Vue from "vue";
import VueRouter from "vue-router";
import store from "../store";
import Home from "../views/Home.vue";
import Register from "../views/Register";
import Login from "../views/Login";
import Posts from "../views/Posts";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/register",
    name: "Register",
    component: Register,
    meta: { guest: true },
  },
  {
    path: "/login",
    name: "Login",
    component: Login,
    meta: { guest: true },
  },
  {
    path: "/posts",
    name: "Posts",
    component: Posts,
    meta: { requiresAuth: true },
  },
];

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

router.beforeEach((to, from, next) => {
  if (to.matched.some((record) => record.meta.requiresAuth)) {
    if (store.getters.isAuthenticated) {
      next();
      return;
    }
    next("/login");
  } else {
    next();
  }
});

router.beforeEach((to, from, next) => {
  if (to.matched.some((record) => record.meta.guest)) {
    if (store.getters.isAuthenticated) {
      next("/posts");
      return;
    }
    next();
  } else {
    next();
  }
});

export default router;

Handling Forbidden Responses

The backend expires tokens after 30 minutes, after which /posts requests return a 401. To handle this gracefully, add an Axios interceptor in main.js right after the default URL declaration. The interceptor watches for 401 responses and sends the user back to the login page automatically.

axios.interceptors.response.use(undefined, function (error) {
  if (error) {
    const originalRequest = error.config;
    if (error.response.status === 401 && !originalRequest._retry) {
  
        originalRequest._retry = true;
        store.dispatch('LogOut')
        return router.push('/login')
    }
  }
})

Following this setup yields a complete authentication flow: users register, log in, access protected routes, and get kicked back to login when their session expires. The full codebase is available on GitHub, with a hosted demo and public API documentation linked below.