JWT Authentication using Axios interceptors

Mihai-Adrian Andrei - Nov 9 '22 - - Dev Community

Hello πŸ‘‹! In this blog, I will show you how I usually implement axios interceptors when I build an app that requires authentication. In this case, we will use React, but in can easily be ported to another framework (Most of the time I did it in Vue).

We will use the backend from this blog post.

For this, I've created a starter repository for us to focus only on the refresh token part. You can clone it with this command:

npx degit mihaiandrei97/blog-refresh-token-interceptor react-auth
Enter fullscreen mode Exit fullscreen mode

Inside it, you will see two folders:

  • react-auth-start: here is the code that you will be using for this project.
  • react-auth-finished: here is the final code, if you missed something and you need to check it.

Project explanation

The application has 2 pages:

  • A Login page, with a form where the user can register/login and after that we save the tokens in localStorage.
  • A Home page where we display the user profile if he is logged in.

For the user state management, we will use zustand (because we need to access the tokens inside axios interceptors, and that can't be done with React Context because the state is not accessible outside components).

I like to keep all of my api calls inside a folder called services. With this approach, I can see all the calls used in the app.

Step 1 - Create Axios Interceptor for request

As a first step, let's define the axios interceptors. You can read more about them here, but as a simple explanation, we will use them to execute some code before we make a request, or after we receive a response.

This is what we will implement:

Axios request interceptor

Let's create a file called services/createAxiosClient.js:

Here, we will define a function that will create our axios instance. We will use that instance everywhere in the app, instead of axios. If we do that, for each request/response, our interceptors will be executed.

import axios from 'axios';

export function createAxiosClient({
  options,
  getCurrentAccessToken,
  getCurrentRefreshToken,
  refreshTokenUrl,
  logout,
  setRefreshedTokens,
}) {
  const client = axios.create(options);

  client.interceptors.request.use(
    (config) => {
      if (config.authorization !== false) {
        const token = getCurrentAccessToken();
        if (token) {
          config.headers.Authorization = "Bearer " + token;
        }
      }
      return config;
    },
    (error) => {
      return Promise.reject(error);
    }
  );

 return client;

}
Enter fullscreen mode Exit fullscreen mode

The createAxiosClient takes the following arguments:

  • options: the options that are passed to the axios instance, example: baseUrl, timeout etc.
  • getCurrentAccessToken: a function that provides the accessToken from the store.
  • getCurrentRefreshToken: a function that provides the accessToken from the store.
  • refreshTokenUrl: the url endpoint that should be called when the access token is expired.
  • logout: a function that performs the logout logic when the refreshToken called failed( ex: cleanup storage / redirect to /login)
  • setRefreshedTokens: a function that sets the tokens in store/localStorage.

We could move the logic directly into createAxiosClient instead of passing those helpers functions, but with this approach, we can easily move the axiosInstance to a different state management (ex: Redux), or to a different framework (Vue / Svelte).

The request interceptor that we just wrote does a simple thing. Checks if the specific request requires authentication, and if it does, it calls the method: getCurrentAccessToken, and adds the token to the header in order to be passed along to the server.
With this approach, we no longer have to manually specify the access token for each request that we write. We just need to use this axios instance.

Step 2 - Create the services

Let's create the file where we will put all of our logic that creates the axios instance.

Create a file in the services directory called axiosClient.js.

import { createAxiosClient } from "./createAxiosClient";
import { useAuthStore } from "../src/stores/authStore";

const REFRESH_TOKEN_URL = 'http://localhost:5000/api/v1/auth/refreshToken'
const BASE_URL = 'http://localhost:5000/api/v1/'

function getCurrentAccessToken() {
    // this is how you access the zustand store outside of React.
    return useAuthStore.getState().accessToken
}

function getCurrentRefreshToken() {
    // this is how you access the zustand store outside of React.
    return useAuthStore.getState().refreshToken
}


function setRefreshedTokens(tokens){
    console.log('set tokens...')
}

async function logout(){
    console.log('logout...')
}

export const client = createAxiosClient({
    options: {
        baseURL: BASE_URL,
        timeout: 300000,
        headers: {
            'Content-Type': 'application/json',
        }
    },
    getCurrentAccessToken,
    getCurrentRefreshToken,
    refreshTokenUrl: REFRESH_TOKEN_URL,
    logout,
    setRefreshedTokens
})
Enter fullscreen mode Exit fullscreen mode

In this file, we call the createAxiosClient function and we export the client in order to use it in our services. We have also defined the URL's (BASE_URL and REFRESH_TOKEN_URL), and we used zustand in order to get the tokens from the global state.

Now, let's create the services.js file, where we would store all of our api calls.

import { client } from "./axiosClient";

export function register({ email, password }) {
  return client.post(
    "auth/register",
    { email, password },
    { authorization: false }
  );
}

export function login({ email, password }) {
    return client.post(
      "auth/login",
      { email, password },
      { authorization: false }
    );
  }

export function getProfile() {
  return client.get("/users/profile");
}

Enter fullscreen mode Exit fullscreen mode

Here, we imported the client instance, and we use it to make requests like we would normally do with the axios keyword.
If you notice, for the login/endpoint, we specified authorization: false, because those endpoints are public. If we omit it, then by default it will fire the getCurrentAccessToken function.

Now, let's change the axios calls with the one from services.
Let's go to the Login page and in the action, change the following code:

const url =
      type === "register"
        ? "http://localhost:5000/api/v1/auth/register"
        : "http://localhost:5000/api/v1/auth/login";
const { data } = await axios.post(url, {
      email,
      password,
    });
Enter fullscreen mode Exit fullscreen mode

with:

const response = type === "register" ? await register({email, password}) : await login({email, password});
const { accessToken, refreshToken } = response.data;
Enter fullscreen mode Exit fullscreen mode

Now, if you try to register/login, it should work like before. The interceptor doesn't really have a point for this endpoints, but you could say it is better structured now.

Next, let's go to the Home page.

There, instead of:

useEffect(() => {
    if (isLoggedIn)
      axios
        .get("http://localhost:5000/api/v1/users/profile", {
          headers: {
            Authorization: `Bearer ${accessToken}`,
          },
        })
        .then(({ data }) => {
          setProfile(data);
        })
        .catch((error) => {
          if (error.response.data.message === "TokenExpiredError") {
            logout();
          }
        });
  }, [isLoggedIn, accessToken]);
Enter fullscreen mode Exit fullscreen mode

We can add:

 useEffect(() => {
    if (isLoggedIn) {
      getProfile().then(({data}) => {
        setProfile(data);
      }).catch(error => {
        console.error(error);
      })
   }
  }, [isLoggedIn]);
Enter fullscreen mode Exit fullscreen mode

It looks much cleaner now. You can see that we do not manually set the Authentication header anymore since the axios interceptor does that for us.
Also, probably you noticed that we are not checking anymore for the "TokenExpiredError". We will do that in the response interceptor soon.

Step 3 - Create Axios Interceptor for response

Here, things get a bit more complicated, but I will try to explain it the as good as I can :D. If you have questions, please add them in the comments.

The final code for createAxiosClient.js is:

import axios from "axios";

let failedQueue = [];
let isRefreshing = false;

const processQueue = (error) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve();
    }
  });

  failedQueue = [];
};

export function createAxiosClient({
  options,
  getCurrentAccessToken,
  getCurrentRefreshToken,
  refreshTokenUrl,
  logout,
  setRefreshedTokens,
}) {
  const client = axios.create(options);

  client.interceptors.request.use(
    (config) => {
      if (config.authorization !== false) {
        const token = getCurrentAccessToken();
        if (token) {
          config.headers.Authorization = "Bearer " + token;
        }
      }
      return config;
    },
    (error) => {
      return Promise.reject(error);
    }
  );

  client.interceptors.response.use(
    (response) => {
      // Any status code that lie within the range of 2xx cause this function to trigger
      // Do something with response data
      return response;
    },
    (error) => {
      const originalRequest = error.config;
      // In "axios": "^1.1.3" there is an issue with headers, and this is the workaround.
      originalRequest.headers = JSON.parse(
        JSON.stringify(originalRequest.headers || {})
      );
      const refreshToken = getCurrentRefreshToken();

      // If error, process all the requests in the queue and logout the user.
      const handleError = (error) => {
        processQueue(error);
        logout();
        return Promise.reject(error);
      };

      // Refresh token conditions
      if (
        refreshToken &&
        error.response?.status === 401 &&
        error.response.data.message === "TokenExpiredError" &&
        originalRequest?.url !== refreshTokenUrl &&
        originalRequest?._retry !== true
      ) {

        if (isRefreshing) {
          return new Promise(function (resolve, reject) {
            failedQueue.push({ resolve, reject });
          })
            .then(() => {
              return client(originalRequest);
            })
            .catch((err) => {
              return Promise.reject(err);
            });
        }
        isRefreshing = true;
        originalRequest._retry = true;
        return client
          .post(refreshTokenUrl, {
            refreshToken: refreshToken,
          })
          .then((res) => {
            const tokens = {
              accessToken: res.data?.accessToken,
              refreshToken: res.data?.refreshToken,
            };
            setRefreshedTokens(tokens);
            processQueue(null);

            return client(originalRequest);
          }, handleError)
          .finally(() => {
            isRefreshing = false;
          });
      }

      // Refresh token missing or expired => logout user...
      if (
        error.response?.status === 401 &&
        error.response?.data?.message === "TokenExpiredError"
      ) {
        return handleError(error);
      }

      // Any status codes that falls outside the range of 2xx cause this function to trigger
      // Do something with response error
      return Promise.reject(error);
    }
  );

  return client;
}


Enter fullscreen mode Exit fullscreen mode

The workflow can be seen in this diagram:

Axios response interceptor

Now, let's take it step by step.

The queue implementation:

We create an array failedQueue where we put the requests that failed at the same time when we tried to refresh the token. When is this usefull? When we have multiple calls in paralel. If we make 4 requests, we probably don't want each of them to trigger a refreshToken. In that case, only the first one triggers it, and the other ones are put in the queue, and retried after the refresh is finished.

Refresh token logic

First, we get the token via getCurrentRefreshToken function that was passed to createAxiosClient.
Second, we need to check the following:

  • Do we have a refresh token?
  • Did we receive a response with status 401 and the message TokenExpiredError?
  • Is the url different from the Refresh token url? (because we do not want to trigger it if the refresh token responds with an expired message)
  • Is this the first time we try this request? (originalRequest?._retry !== true)

If all this conditions are true, then we can go further. If the isRefreshing flag is already true, it means we triggered the refresh with an early call, so we just need to add the current call to the queue. If not, then this is the first call, so we change the flag to true, and we proceed with the refreshToken call to the back-end. If the call is successful, we call 'setRefreshTokens' that was passed to the client, we process the queue(start all the requests from the queue with the new tokens), and we retry the original request that triggered the refresh.

If the refresh token was missing, or it was expired, we just process the queue as an error and we logout the user.

Now, the last thing we need to do write the logic for setRefreshTokens and logout.

Go to services/axiosClient.js and change them like this:

function setRefreshTokens(tokens){
    console.log('set refresh tokens...')
    const login = useAuthStore.getState().login
    login(tokens)
}

async function logout(){
    console.log('logout...')
    const logout = useAuthStore.getState().logout
    logout()
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

And that's it. Now, by using axios interceptors, your app should automatically add the access token to the header and also handle the refresh token silently, in order to keep the user authenticated πŸŽ‰πŸŽ‰πŸŽ‰.

If you have any questions, feel free to reach up in the comments section.

. . . . . . . . . . .
Terabox Video Player