Refresh Access Token with Axios Interceptors in React.js (with Typescript) | #2

Refresh Access Token with Axios Interceptors in React.js (with Typescript) | #2

In today’s article, we will look at how to use axios interceptors in our applications that help us to refresh our access tokens.

Before going into further details let’s clear out some definitions first, shall we?

Basics

Interceptors

Interceptors are a way to monitor or transform requests and responses that our application sends and receives.

We usually use interceptors to add some header (like Authorization etc) to each request before making them or transforming data that we receive from the server.

Below is the basic example of an interceptor that adds Authorization header to each request before making them.

import axios from "axios";

axios.interceptors.request.use((request) => {
  const accessToken = localStorage.getItem("accessToken");

  if (accessToken) {
    request.headers.Authorization = JSON.parse(accessToken);
  }

  return request;
});

Similarly, we can intercept incoming responses before they reach to our application code.

This ability to silently monitor and transform requests and responses is really beautiful. Isn’t it?

Promises

You might think that bro we already know what a promise is.

And you are right, you do know about them but still, hear me out so that we are on the same page.

Below is the definition of a Promise that I copy-pasted from MDN.

ThePromise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

Below is how we create a promise in Javascript.

const myPromise = new Promise((resolve, reject) => {
    resolve("Success!");
    // or
    reject("Failure");
});

Here we pass an executor function to our promise while creating it.

It is the job of the executor function to call the resolve or reject function to settle our promise.

Once the promise is settled we capture some value either that resolve or reject called with, in our then and catch callbacks respectively.

2 things to note here -

  • The executor function is synchronous but then and catch callbacks are not, they are asynchronous.

  • If we don’t call either resolve or reject then our then or catch callbacks will never run and our promise will never settle.

To explain the first point guess the output of the below program —

console.log(1);

setTimeout(() => {
  console.log(2);
}, 0);

const promise = new Promise((resolve, reject) => {
  console.log(3);
  resolve(4);
});

promise.then((value) => console.log(value));

If your answer is —

1
3
4
2

then you are right.

To answer these types of questions, we need to have an understanding of Event Loop in JavaScript.

What I always do in these types of questions is to figure out which statements are synchronous and which statements belong to the macrotask queue or microtask queue.

Priority order -

Synchronous statements > Microtask statements > Macrotask statements

Below is the separation —

console.log(1); // Synchronous

setTimeout(() => {
  console.log(2); // Macrotask
}, 0);

const promise = new Promise((resolve, reject) => {
  console.log(3); // Synchronous
  resolve(4);
});

promise.then((value) => console.log(value)); // Microtask

To understand the 2nd point, what is the output of the below program —

console.log(1);

const promise = new Promise((resolve, reject) => {});

promise.then(() => {
  console.log(2);
});

Output —

1

then the program exits.

We didn’t call the resolve function of the promise that’s why our then callback never ran or we can say our promise never settled.

Hope you got the above 2 points.

The above 2 points are important in order to understand the solution I will show you further.


Backend

Now let’s set up the backend for our demo application.

Our backend will have the following routes —

  • POST/login (public route)

  • POST/signup (public route)

  • POST/access-token (public route)

  • GET/posts (protected route)

  • GET/todos (protected route)

  • GET/users (protected route)

Upon /login, BE will give accessToken and refreshToken to FE which will be stored in the browser’s localstorage.

accessToken has an expiry of 30 seconds and refreshToken will expire after 1 minute.

Deployed Url — Link

You can check out the complete backend code in my GitHub repo.


Frontend

Now is the time for the frontend part.

I am using React with TS using Vite as the build tool.

Our app has 3 pages, /, /login, and /sign-up.

  • / — will show data from our BE’s /posts, /todos, and /users protected routes.

  • /login — works as the name suggests.

  • /sign-up — works as the name suggests.

Deployed Url — Link

Check out the complete frontend code in my GitHub repo.


Axios Setup

Request Interceptor

axiosInstance.interceptors.request.use((request) => {
  const accessToken = localStorage.getItem("accessToken");

  if (accessToken) {
    request.headers.Authorization = JSON.parse(accessToken);
  }

  return request;
});

It is just picking accessToken from localstorage and assigning its value to the Authorization header on every request made by the app.

Response Interceptor

import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";

const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_BACKEND_BASE_URL ?? "http://localhost:3000",
});

interface FailedRequests {
  resolve: (value: AxiosResponse) => void;
  reject: (value: AxiosError) => void;
  config: AxiosRequestConfig;
  error: AxiosError;
}

let failedRequests: FailedRequests[] = [];
let isTokenRefreshing = false;

axiosInstance.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const status = error.response?.status;
    const originalRequestConfig = error.config!;

    if (status !== 401) {
      return Promise.reject(error);
    }

    if (isTokenRefreshing) {
      return new Promise((resolve, reject) => {
        failedRequests.push({
          resolve,
          reject,
          config: originalRequestConfig,
          error: error,
        });
      });
    }

    isTokenRefreshing = true;

    try {
      const response = await axiosInstance.post("/access-token", {
        refreshToken: JSON.parse(localStorage.getItem("refreshToken") ?? ""),
      });
      const { accessToken = null, refreshToken = null } = response.data ?? {};

      if (!accessToken || !refreshToken) {
        throw new Error(
          "Something went wrong while refreshing your access token"
        );
      }

      localStorage.setItem("accessToken", JSON.stringify(accessToken));
      localStorage.setItem("refreshToken", JSON.stringify(refreshToken));

      failedRequests.forEach(({ resolve, reject, config }) => {
        axiosInstance(config)
          .then((response) => resolve(response))
          .catch((error) => reject(error));
      });
    } catch (_error: unknown) {
      console.error(_error);
      failedRequests.forEach(({ reject, error }) => reject(error));
      localStorage.setItem("accessToken", "");
      localStorage.setItem("refreshToken", "");
      return Promise.reject(error);
    } finally {
      failedRequests = [];
      isTokenRefreshing = false;
    }

    return axiosInstance(originalRequestConfig);
  }
);

export { axiosInstance };

I know it's a lot, let me break it down for you.

So the flow is if we get an error response from BE with the status code (401 Unauthorized), our interceptor’s error callback will run.

It needs to return a promise so that the error can reach to our application code.

If the status code is not 401 then we simply return a rejected promise with Promise.reject().

We are using an isTokenRefreshing variable that will store the status while we are refreshing the access token.

While the token is refreshing, any failed requests with 401 status code we return a promise and store its resolve and reject functions in our failedRequests array. We already know our application code will wait until the resolve or reject function is called to settle the promise.

Once the accessToken is refreshed we simply re-initiate the failed requests.

All this work happened and our application didn’t have any clue that the requests it had sent earlier failed. It just received the data it requested earlier.

For some reason, if we get some error while refreshing the accessToken we simply call the reject function of all failedRequests so that their promises can be settled.

And in the last, we simply reset the failedRequests and isTokenRefreshing to their initial values.

Demo

Normal Case

Access Token Expired

Refresh Token Expired Too

That’s it from my side.

Hope you learned something new today.

Peace ✌️.

Follow me here on Hashnode or LinkedIn for further updates from my side.