Guide to Authentication with Access and Refresh Tokens in React/Next.js

Guide to Authentication with Access and Refresh Tokens in React/Next.js

Introduction

Before allowing access to a resource or service, authentication is the process of confirming a user's or system's identity. Making sure that only authorized users may engage with the system is the first and most important step in protecting any application.

To put it another way, authentication provides an answer to the query, "Who are you?"
Usually, the procedure entails:

  • Submission of Credentials: Users enter their login information, including their password and username.

  • Verification: A secure database or authentication service is used by the program to confirm these credentials.

  • Access Granting: The user is given access and frequently a token(access and refresh) or session for further communication after successful verification.

Access Token

These are short-lived tokens provided after successful user authentication, with expiry generally in the range of 60 minutes to 24 hours. They are included in API requests to prove that the user is authenticated and authorized to perform specific actions.

Access tokens are typically sent in the Authorization header as a Bearer token:

Authorization: Bearer <access_token>

Refresh Token

These are long-lived tokens used to obtain new access tokens when the current ones expire. Unlike access tokens, refresh tokens are not used directly to access resources.

When an access token expires, the client sends the refresh token to the server. The server verifies the refresh token, issues a new access token, and optionally rotates the refresh token for added security.

Architecture

Implementation

In this article, we will be using React 18 and @tanstack/react-query for the API integration. But the same is applicable for NextJs as well, with minor modifications.

Login Flow

Once the user lands on the login page, we accept the username and password and trigger the authentication API. The response looks something below:

clientMeta: {name: "web_admin"}
data: {
  identity: {
      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiNjZmNjU3MjJmYzJlNWY4MDA0MDkyZmMwIiwidXNlcklkIjoiNjZmMjkxMzYwNjEzNmIxODFiY2EyYjU1IiwidHlwZSI6ImFjY2VzcyIsInRva2VuVmVyc2lvbiI6MTIxLCJpc0VtYWlsVmVyaWZpZWQiOmZhbHNlLCJpYXQiOjE3MzM4MTI5MDAsImV4cCI6MTczNTEwODkwMH0.XAdC9cWu3J_Go13C_d7tsOOlUtWnhN9JmOyVN-rLu6k",
      "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eUlkIjoiNjZmNjU3MjJmYzJlNWY4MDA0MDkyZmMwIiwidXNlcklkIjoiNjZmMjkxMzYwNjEzNmIxODFiY2EyYjU1IiwidHlwZSI6InJlZnJlc2giLCJ0b2tlblZlcnNpb24iOjEyMSwiaWF0IjoxNzMzODEyOTAwLCJleHAiOjIwNDkzODg5MDB9.A2q3VWxGDGsTorWa-YwbOVM6U8qFPKspq1y4m1rLolk",
    }
  }
date: "2024-12-10T06:41:40.694Z"
success: true
version: "v1"

We have created a custom query hook using react-query to call the auth api below:

  const {
    mutateAsync: postVerifyPasswordMutation,
    data: postVerifyPasswordData,
    status: postVerifyPasswordStatus,
  } = usePostVerifyPassword();

More info about this is available on the official react query docs.

We utilize the status key to determine whether the response was successful or unsuccessful after calling the mutation function with their login credentials.

  useEffect(() => {
    if (postVerifyPasswordStatus === 'success') {
      setCookie(
        null,
        'accessToken',
        postVerifyPasswordData?.identity?.accessToken,
        {
          maxAge: ACCESS_TOKEN_MAX_AGE, //24 hrs in our case
          path: '/',
        },
      );
      setCookie(
        null,
        'refreshToken',
        postVerifyPasswordData?.identity?.refreshToken,
        {
          maxAge: REFRESH_TOKEN_MAX_AGE, //30 days in our case
          path: '/',
        },
      );
      navigate('/', { replace: true });
    } else if (postVerifyPasswordStatus === 'error') {
      enqueueSnackbar('Invalid Email or Password', {
        variant: 'error',
      });
    }
  }, [postVerifyPasswordStatus]);

The setCookie method from the nookies package is used in the code above to save the access-token and refresh-token, provided that the login credentials are valid. Additionally, they have an expiration date, after which they will be automatically deleted from cookies, however they may be retained depending on the use case. We merely display a snackbar with an error message if credentials are invalid. The snackbar was implemented using the notistack package.

Note: Tokens can be stored in localStorage as well. But there are considerations need to be done before choosing where to store it. More info about is available in this article.

API integration

Once the access-token is present in cookies, it can be passed in API headers to ensure the user is authenticated.

import axios from 'axios';
import { errorHandler } from '../utils/errorHandler';
import { parseCookies } from 'nookies';

const apiMethod = async (
  method,
  endpoint,
  data = null,
  additionalHeaders = {},
  config = {},
  signal = null,
) => {
  const cookies = parseCookies();
  const headers = {
    Authorization: cookies?.accessToken ? `Bearer ${cookies.accessToken}` : '',
    ...additionalHeaders,
  };

  try {
    const response = await axios({
      method,
      url: endpoint,
      data,
      headers,
      ...config,
      signal,
    });
    return response?.data?.data;
  } catch (error) {
    if (axios.isCancel(error)) {
      //Cancel request case
    } else {
      errorHandler(error);
      throw error;
    }
  }
};

export default apiMethod;

With the aid of axios, we submit API calls using the aforementioned wrapper method. We pass the access token in the Authorization header after first retrieving it from cookies. The API will function properly and provide the required data if the access token is legitimate.

API failure

The api queries will fail if the token is invalid (expires before 24 hours) or if it is deleted from cookies after it expires. In this instance, servers reply with the status code 401 when there is a problem with the access token. This indicates that the user is not permitted to access the resources that have been requested.

errorHandler function in the above snippet will handle such cases and can be defined as follows:

import { destroyCookie, parseCookies } from 'nookies';

export const errorHandler = async (error) => {
  const cookies = parseCookies();

  if (error?.response?.status === 401) {
    let shouldLogout = true;

    try {
      if (cookies?.refreshToken) {
        const tokenRefreshed = await refreshTokenHandler(cookies?.refreshToken);
        if (tokenRefreshed) {
          window.location.reload();
          shouldLogout = false;
        }
      }
    } catch (refreshError) {
      // Log the refresh token error
    }

    if (shouldLogout) {
      handleLogout();
    }
  }
};

This function determines whether the error code 401, which denotes an incorrect access token, is present. Ideally, we should log the user out in this situation. However, we first see if cookies contain a refresh token. If so, we verify its legitimacy and make a request to a different API to obtain a fresh access and refresh token. After obtaining the new tokens, we simply reload the page so that APIs are called with the updated and valid access token and set the shouldLogout variable to false to prevent the logout method from being triggered.

The refreshTokenHandler function, definition is provided below:

export const refreshTokenHandler = async (refreshToken) => {
  const response = await fetch(
   <API_ENDPOINT>,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${refreshToken}`,
      },
    },
  );

  if (!response.ok) {
    return null; // Indicate failure to refresh the token
  }

  const json = await response.json();
  const data = json?.data?.identity;

  // Set new cookies
  setCookie(null, 'accessToken', data?.accessToken, {
    maxAge: ACCESS_TOKEN_MAX_AGE,
    path: '/',
  });
  setCookie(null, 'refreshToken', data?.refreshToken, {
    maxAge: REFRESH_TOKEN_MAX_AGE,
    path: '/',
  });

  return true; // Indicate successful token refresh
};

Upon success, the above function stores the new tokens in cookies. In case the API fails, it simply returns which doesn’t change the shouldLogout variable.

If the refresh-token is missing or is invalid, we trigger the logout function provided below:

const handleLogout = () => {
  destroyCookie(null, 'accessToken');
  destroyCookie(null, 'refreshToken');
  window.location.href = '/login';
};

Once the user is back on the login page, the same process as before is followed to make sure only authenticated users can access the website’s content.

Conclusion

The foundation of safe and intuitive web apps is authentication, which may be successfully implemented in a frontend framework such as React or Next.JS necessitates a thorough comprehension of access and refresh tokens. Through appropriate token storage in secure cookies, workflow management, and smooth connectivity with backend services, we can create a strong authentication system that safeguards user information while providing a seamless user experience.

We have examined the design, best practices, and practical code implementation of token-based authentication in this guide. The goal of authentication is to establish dependability and trust in each encounter, not merely to log individuals in.