How to auto-refresh jwts using Axios interceptors

lewiskori

Lewis kori

Posted on February 6, 2021

How to auto-refresh jwts using Axios interceptors

I've covered JWT authentication in some of my previous posts. For a quick recap, I'll briefly go over what JWTs are.

What are JWTs?

JSON Web Token (JWT) is an Internet standard for creating JSON-based access tokens that assert some number of claims. For example, a server could generate a token that has the flag "logged in as admin" or "logged in like this user" and provide that to a client. The client could then use that token to prove that it is logged in as admin. The tokens are signed by one party's private key (usually the server's) so that both parties can verify that the token is legitimate. The tokens are designed to be compact, URL-safe, and usable especially in a web-browser single-sign-on (SSO) context. JWT claims can be typically used to pass the identity of authenticated users between an identity provider and a service provider.
Unlike token-based authentication, JWTs are not stored in the application's database. This is in effect makes them stateless.

JWT authentication typically involves two tokens. These are an access token and refresh token. The access token authenticates HTTP requests to the API and for protected resources must be provided in the request headers.

The token is usually shortlived to enhance security and therefore to avoid users or applications from logging in every few minutes, the refresh token provides a way to retrieve a newer access token. The refresh token typically has a longer expiry period than the access token.

In my previous posts, I used Django to implement JWT authentication but this can be achieved in most backend frameworks.

In this walkthrough, we'll be using Axios which is a popular promise-based HTTP client written in JavaScript to perform HTTP communications. It has a powerful feature called interceptors. Interceptors allow you to modify the request/response before the request/response reaches its final destination.

We'll use vuex for global state management but you can just as easily implement the config in any javascript framework or method you choose.

project initialization

Since this is a Vue project, we'll first need to initialize a Vue project. Check out the vue.js installation guide for more information.

vue create interceptor
Enter fullscreen mode Exit fullscreen mode

After initializing the project we'll need to install vuex and a neat library called vuex-persistedstate. This will persist our state to local storage as the store data is cleared on the browser tab refresh.

yarn add vuex vuex-persistedstate
Enter fullscreen mode Exit fullscreen mode

setting up the store

To initialize the vuex store, we'll have to create a store folder in the src directory. In the store folder, create an index.js file and fill it with the following content.

import Vue from "vue";
import Vuex from "vuex";
import createPersistedState from "vuex-persistedstate";
import router from "../router"; // our vue router instance

Vue.use(Vuex);

export default new Vuex.Store({
  plugins: [createPersistedState()],
  state: {},
  mutations: {},
  actions: {},
  getters: {}
});

Enter fullscreen mode Exit fullscreen mode

We'll leave this as it is for now. We'll populate the various sections later on. For now, we'll register the store in the main.js file.

import Vue from "vue";
import App from "./App.vue";
import store from "./store";

new Vue({
  store,
  render: h => h(App)
}).$mount("#app");
Enter fullscreen mode Exit fullscreen mode

state and mutations

The only way to actually change state in a Vuex store is by committing a mutation. Vuex mutations are very similar to events: each mutation has a string type and a handler. The handler function is where we perform actual state modifications, and it will receive the state as the first argument.

Our application will have a few state objects and mutations.

  state: {
    refresh_token: "",
    access_token: "",
    loggedInUser: {},
    isAuthenticated: false
  },
  mutations: {
    setRefreshToken: function(state, refreshToken) {
      state.refresh_token = refreshToken;
    },
    setAccessToken: function(state, accessToken) {
      state.access_token = accessToken;
    },
    // sets state with user information and toggles 
    // isAuthenticated from false to true
    setLoggedInUser: function(state, user) {
      state.loggedInUser = user;
      state.isAuthenticated = true;
    },
    // delete all auth and user information from the state
    clearUserData: function(state) {
      state.refresh_token = "";
      state.access_token = "";
      state.loggedInUser = {};
      state.isAuthenticated = false;
    }
  },
Enter fullscreen mode Exit fullscreen mode

The code is so far pretty self-explanatory, the mutations are updating our state values with relevant information, but where is this data coming from? Enter actions.

Vuex Actions

Actions are similar to mutations, the differences being that:

  • Instead of mutating the state, actions commit mutations.
  • Actions can contain arbitrary asynchronous operations.

This means that actions call the mutation methods which will then update the state. Actions can also be asynchronous allowing us to make backend API calls.

  actions: {
    logIn: async ({ commit, dispatch }, payload) => {
      const loginUrl = "v1/auth/jwt/create/";
      try {
        await axios.post(loginUrl, payload).then(response => {
          if (response.status === 200) {
            commit("setRefreshToken", response.data.refresh);
            commit("setAccessToken", response.data.access);
            dispatch("fetchUser");
            // redirect to the home page
            router.push({ name: "home" });
          }
        });
      } catch (e) {
        console.log(e);
      }
    },
    refreshToken: async ({ state, commit }) => {
      const refreshUrl = "v1/auth/jwt/refresh/";
      try {
        await axios
          .post(refreshUrl, { refresh: state.refresh_token })
          .then(response => {
            if (response.status === 200) {
              commit("setAccessToken", response.data.access);
            }
          });
      } catch (e) {
        console.log(e.response);
      }
    },
    fetchUser: async ({ commit }) => {
      const currentUserUrl = "v1/auth/users/me/";
      try {
        await axios.get(currentUserUrl).then(response => {
          if (response.status === 200) {
            commit("setLoggedInUser", response.data);
          }
        });
      } catch (e) {
        console.log(e.response);
      }
    }
  },
Enter fullscreen mode Exit fullscreen mode

We'll go over the methods one by one.
The login function does exactly what it's called. This will make a backend call to our jwt creation endpoint. We expect the response to contain a refresh and access token pair.
Depending on your implementation this can change. So, implement the method accordingly.
We then call the mutations that'll set the access and refresh tokens to state. If successful, we'll call the fetchUser action by using the dispatch keyword. This is a way of calling actions from within vuex.

The refreshToken sends an HTTP POST request to our backend with the current refresh token and if valid, receives a new access token, this then replaces the expired token.

Getters

Finally, we'll expose our state data through getters so as to make it easy to reference this data.

  getters: {
    loggedInUser: state => state.loggedInUser,
    isAuthenticated: state => state.isAuthenticated,
    accessToken: state => state.access_token,
    refreshToken: state => state.refresh_token
  }
Enter fullscreen mode Exit fullscreen mode

Axios interceptors

So far so good. The most difficult part has been covered!
To set up the interceptors we'll create a helpers folder in our src directory and create a file called axios.js

This will contain the following code.

import axios from "axios";
import store from "../store";
import router from "../router";

export default function axiosSetUp() {
  // point to your API endpoint
  axios.defaults.baseURL = "http://127.0.0.1:8000/api/";
  // Add a request interceptor
  axios.interceptors.request.use(
    function(config) {
      // Do something before request is sent
      const token = store.getters.accessToken;
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    },
    function(error) {
      // Do something with request error
      return Promise.reject(error);
    }
  );

  // Add a response interceptor
  axios.interceptors.response.use(
    function(response) {
      // Any status code that lie within the range of 2xx cause this function to trigger
      // Do something with response data
      return response;
    },
    async function(error) {
      // Any status codes that falls outside the range of 2xx cause this function to trigger
      // Do something with response error
      const originalRequest = error.config;
      if (
        error.response.status === 401 &&
        originalRequest.url.includes("auth/jwt/refresh/")
      ) {
        store.commit("clearUserData");
        router.push("/login");
        return Promise.reject(error);
      } else if (error.response.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;
        await store.dispatch("refreshToken");
        return axios(originalRequest);
      }
      return Promise.reject(error);
    }
  );
}

Enter fullscreen mode Exit fullscreen mode

From the code above, we'll be importing axios and configuring it inside the axiosSetup method. The first thing we'll do is declaring the baseURL for this particular axios instance. You can point this to your backend URL. The configuration will make it easier when making API calls as we won't have to explicitly type the entire URL on each HTTP request.

request interceptor

Our first interceptor will be a request interceptor. We'll modify each request coming from our frontend by appending authorization headers to the request. This is where we'll be utilizing the access token.

// Add a request interceptor
  axios.interceptors.request.use(
    function(config) {
      // Do something before request is sent
      // use getters to retrieve the access token from vuex 
      // store
      const token = store.getters.accessToken;
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    },
    function(error) {
      // Do something with request error
      return Promise.reject(error);
    }
  );
Enter fullscreen mode Exit fullscreen mode

What we're doing is checking if there's an access token in store and if it's available, modifying our Authorization header so as to utilize this token on each and every request.
In case the token isn't available, the headers won't contain the Authorization key.

response interceptor

We'll be extracting the axios config for this section. Kindly check out their documentation for more insight on what it contains.

// Add a response interceptor
  axios.interceptors.response.use(
    function(response) {
      // Any status code that lie within the range of 2xx cause this function to trigger
      // Do something with response data
      return response;
    },
    // remember to make this async as the store action will 
    // need to be awaited
    async function(error) {
      // Any status codes that falls outside the range of 2xx cause this function to trigger
      // Do something with response error
      const originalRequest = error.config;
      if (
        error.response.status === 401 &&
        originalRequest.url.includes("auth/jwt/refresh/")
      ) {
        store.commit("clearUserData");
        router.push("/login");
        return Promise.reject(error);
      } else if (error.response.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;
        // await execution of the store async action before 
        // return
        await store.dispatch("refreshToken");
        return axios(originalRequest);
      }
      return Promise.reject(error);
    }
  );
Enter fullscreen mode Exit fullscreen mode

We have two callbacks in the response interceptors. One gets executed when we have a response from the HTTP call and another one gets executed when we have an error.
We will return our response when there is no error. We’ll handle the error if there is any.

The first if statement checks whether the request received a 401(unauthorized) error which is what happens when we try and pass invalid credentials to our backend and whether our original request's URL was to the refresh endpoint.
If this was the case, it means that our refresh token is also expired and hence, we'll log out the user and clear their store data. We'll then redirect the user to the login page so as to retrieve new access credentials.

In the second block(else if), we'll check again if the request has failed with status code 401(unauthorized) and this time if it failed again.
In case it's not a retry, we'll dispatch the refreshToken action and retry our original HTTP request.

Finally, for all other failed requests whose status falls outside the range of 2xx, we'll return the rejected promise which can be handled elsewhere in our app.

making axios globally available in our vue app

With the interceptors all set up, we'll need a way for our to access axios and utilize all these goodies!
To do that, we'll import the axiosSetup method in our main.js file.

import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import axiosSetup from "./helpers/interceptors";

// call the axios setup method here
axiosSetup()

new Vue({
  store,
  render: h => h(App)
}).$mount("#app");
Enter fullscreen mode Exit fullscreen mode

That's it!! we've set up Axios interceptors and they're globally available on our app. Every Axios call will implement them be it in components or Vuex!

I hope you found the content helpful!
If you have any questions, feel free to leave a comment. My Twitter dm is always open and If you liked this walkthrough, subscribe to my mailing list to get notified whenever I make new posts.

open to collaboration

I recently made a collaborations page on my website. Have an interesting project in mind or want to fill a part-time role?
You can now book a session with me directly from my site.

💖 💪 🙅 🚩
lewiskori
Lewis kori

Posted on February 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related