DEV Community

Cover image for JWT Authentication using Axios interceptors
Mihai-Adrian Andrei
Mihai-Adrian Andrei

Posted on

JWT Authentication using Axios interceptors

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.

Top comments (7)

Collapse
 
sk0le profile image
Amel Islamovic

Hello, I just want to ask question, I tried converting this to typescript and I don't have any luck.
This is error that I get:

Argument of type '{ authorization: boolean; }' is not assignable to parameter of type 'AxiosRequestConfig<{ email: any; password: any; }>'.
Enter fullscreen mode Exit fullscreen mode

And this happens when I call client.post()
Any help man.

Collapse
 
sipkoepp profile image
sipkoepp • Edited

I ran into the same issue. You need to overwrite the AxiosRequestConfig. Just place this anywhere in your code. I placed it on top of the createAxiosClient function.

// Add a new property to AxiosRequestConfig interface
declare module "axios" {
  export interface AxiosRequestConfig {
    authorization?: boolean;
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mihaiandrei97 profile image
Mihai-Adrian Andrei

Thanks! Good to know

Collapse
 
mihaiandrei97 profile image
Mihai-Adrian Andrei

Hello. Can you create a repo and share the url So I can check?

Collapse
 
talinthedev profile image
Talin Sharma

I switched from React to Vue recently and I really don't want to go back so is there a simple way to use most of this code here in Vue without rewriting major portions of it? I'm fine with minor rewrites, that's expected but I don't want to basically rewrite the whole thing myself. Thanks for this and the backend article! They helped a ton!

Collapse
 
mihaiandrei97 profile image
Mihai-Adrian Andrei

The axios part is the same. The useEffects could be changed into created or watchers in vue

Collapse
 
talinthedev profile image
Talin Sharma

Okay, thanks!