How to Efficiently Manage JWT Authentication (Refresh & Access Tokens) in React Without Repeated Server Calls and Implement a Centralized Alert System

I'm working on a React application that requires JWT authentication with both access and refresh tokens. I want to manage the authentication process in a way that prevents multiple requests for the refresh token and handles token expiration smoothly. Additionally, I want to centralize the alert system for handling success, error, and informational messages across the entire application. ** What I want to achieve:**

JWT Authentication:

Proper handling of access and refresh tokens in the React app. When the access token expires, I want to refresh it using the refresh token, but without making repeated server requests or causing race conditions.

Automatically retry the original request after refreshing the access token. Centralized Alert System:

I need an alert system that can handle and display error messages from API responses (both nested and non-nested). The alert system should be available globally across all components. Alerts should be dynamically triggered based on different responses (error, success, etc.).

Problem: I use the Djoser for backend handling and react for frontend handling I’m confused about how to manage the refresh token mechanism without making repeated calls to the server or causing race conditions. I’m also trying to implement a centralized alert system that works globally for all responses from the server, including handling different types of errors in a structured manner.

this is the code i use for axios instance and confused about how to handle refresht token effectively

import axios from 'axios';
import { triggerAlertExternally } from '../context/AlertContext';

// Base URL for your API
const API_URL = 'http://127.0.0.1:8000/api';

// Create Axios instance
const axiosInstance = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Axios instance for managing token refresh
const refreshTokenAxios = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Keep track of the refresh process to avoid race conditions
let isRefreshing = false;
let failedRequestsQueue = [];

// Function to add access token to the request
const addAccessTokenToRequest = (config) => {
  const accessToken = localStorage.getItem('accessToken');
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
};

// Add request interceptor for adding Authorization header
axiosInstance.interceptors.request.use(addAccessTokenToRequest, (error) => Promise.reject(error));

// Refresh token handling logic
const refreshAccessToken = async () => {
  try {
    const refreshToken = localStorage.getItem('refreshToken');
    if (!refreshToken) throw new Error('No refresh token found, logging out...');

    const response = await refreshTokenAxios.post('/auth/jwt/refresh/', {
      refresh: refreshToken,
    });

    const { access, refresh } = response.data;
    localStorage.setItem('accessToken', access);
    localStorage.setItem('refreshToken', refresh);

    return access; // Return the new access token
  } catch (error) {
    triggerAlertExternally('Session expired. Please log in again.', 'error');
    logoutUser();
    throw error; // Ensure the error is thrown for further handling
  }
};

// Handle token refresh and retry the original request
const retryFailedRequests = (accessToken) => {
  failedRequestsQueue.forEach(({ resolve }) => resolve(accessToken));
  failedRequestsQueue = []; // Clear the queue after retrying
};

// Add response interceptor for handling 401 errors
axiosInstance.interceptors.response.use(
  (response) => response, // Return successful responses as is
  async (error) => {
    const originalRequest = error.config;

    // If the error is 401 and it's not a refresh request
    if (error.response?.status === 401 && !originalRequest._retry) {
      // Prevent multiple refresh token requests (race condition)
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedRequestsQueue.push({ resolve, reject });
        });
      }

      // Mark this request as having a retry to avoid infinite loops
      originalRequest._retry = true;

      isRefreshing = true;
      try {
        const accessToken = await refreshAccessToken();
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        retryFailedRequests(accessToken); // Retry the failed requests with the new token
        return axiosInstance(originalRequest); // Retry the original request with the new token
      } catch (err) {
        return Promise.reject(err); // Handle any errors if token refresh fails
      } finally {
        isRefreshing = false; // Reset the refreshing flag
      }
    }

    // Handle non-401 errors (or if refresh fails)
    return Promise.reject(error);
  }
);

// Logout user and clear all session data
function logoutUser() {
  localStorage.removeItem('accessToken');
  localStorage.removeItem('refreshToken');
  localStorage.removeItem('user'); // Clear user data from localStorage
  // window.location.href = '/login'; // Redirect to login page
}

export default axiosInstance;

this is the alert system my approach is to get the error messages directly from the component and then display with proper extracting it form the response

import React, { createContext, useState, useContext, useEffect } from 'react';
import { Snackbar, Alert } from '@mui/material';
import { Error as ErrorIcon, CheckCircle as SuccessIcon, Info as InfoIcon } from '@mui/icons-material';

// Create a context for the alert system
const AlertContext = createContext();

// Global reference to the showAlert function for external access
let externalShowAlert = null;

// Function to expose the showAlert globally
export const setExternalShowAlert = (showAlertFunc) => {
  externalShowAlert = showAlertFunc;
};

// Function to trigger alert externally
export const triggerAlertExternally = (message, severity = 'error', statusCode = 500) => {
  if (externalShowAlert) {
    externalShowAlert(message, severity, statusCode);
  }
};

// Helper function to format error messages for Djoser responses
const formatErrorMessage = (error) => {
  if (typeof error === 'string') {
    // Direct string error message
    return `${error}`;
  }

  if (Array.isArray(error)) {
    // Error array: Join them with a separator
    return error.join(' ');
  }

  if (typeof error === 'object') {
    // Nested error handling
    const formattedMessages = [];
    Object.keys(error).forEach((key) => {
      const value = error[key];
      if (Array.isArray(value)) {
        // If the value is an array, join and display
        formattedMessages.push(`${key}: ${value.join(', ')}`);
      } else if (typeof value === 'object') {
        // If the value is an object, recursively format it
        formattedMessages.push(`${key}: ${formatErrorMessage(value)}`);
      }
    });
    return formattedMessages.join(' ');
  }

  return 'An unexpected error occurred'; // Default fallback
};

// Define custom icons for each alert type
const getAlertIcon = (severity) => {
  switch (severity) {
    case 'success':
      return <SuccessIcon style={{ color: 'green' }} />;
    case 'info':
      return <InfoIcon style={{ color: 'blue' }} />;
    case 'error':
    default:
      return <ErrorIcon style={{ color: 'red' }} />;
  }
};

// AlertProvider component
export const AlertProvider = ({ children }) => {
  const [alert, setAlert] = useState(null);
  const [statusCode, setStatusCode] = useState(null);

  // Function to display the alert
  const showAlert = (message, severity = 'error', statusCode = 500) => {
    const formattedMessage = formatErrorMessage(message);
    setAlert({ message: formattedMessage, severity });
    setStatusCode(statusCode);
    setTimeout(() => setAlert(null), 5000); // Hide alert after 5 seconds
  };

  // Set the global showAlert function when the component mounts
  useEffect(() => {
    setExternalShowAlert(showAlert);
  }, [showAlert]);

  return (
    <AlertContext.Provider value={{ showAlert }}>
      {children}
      {alert && (
        <Snackbar open={Boolean(alert)} autoHideDuration={5000}>
          <Alert
            severity={alert.severity}
            sx={{ width: '100%' }}
            icon={getAlertIcon(alert.severity)} // Dynamically set the icon based on severity
          >
            {statusCode && <strong>Status {statusCode} - </strong>}
            {alert.message}
          </Alert>
        </Snackbar>
      )}
    </AlertContext.Provider>
  );
};

// Custom hook to use the alert context
export const useAlert = () => useContext(AlertContext);

Regarding managing JTW.

I've implemented similar flow recently in React client-side application which handles auth with access/refresh token pair. You can check it here: https://github.com/zavvdev/fe-infra.

Basically, I've done it with axios + axios-retry flow where:

  1. Each request that returned invalid_token response should be retried (but specific amount of time to prevent infinite retries). Retries for mutation requests are also included but only for this type of requests.
  2. While requests are being retried, refresh token is triggered only once within specific period to prevent multiple token refresh requests.
  3. After successful refresh - all previously failed request will be retried with new token if refresh has been successfully completed within specified retry time frame.

For more details check repo above. Hope it helps.

Вернуться на верх