Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Client-Side Token Refresh and Retry

Hello! Welcome back to our course.

In our last lesson, we configured our server with middleware that reliably detects an expired access token and responds with a 401 Unauthorized status. Now, it's time to build the client-side logic that intelligently handles this signal.

This lesson is dedicated to implementing a client-side interceptor. This powerful pattern will allow our application to automatically catch the 401 error, use our refresh token to get a new access token, and seamlessly retry the original request that failed. The goal is to make this entire process invisible to the user, creating a smooth, uninterrupted experience.

By the end of this lesson, you will be able to implement an interceptor that automatically refreshes an expired access token and retries a single failed API request.

1. The Interceptor Pattern: Centralizing API Logic

As a lead developer, you know the importance of maintainable and DRY (Don't Repeat Yourself) code. Manually handling token expiration in every single data-fetching function would be highly repetitive and error-prone.

This is where the interceptor pattern shines. Most modern HTTP client libraries, like axios, allow you to "intercept" requests before they are sent and responses before they are handled by your application code. This provides a single, centralized place to manage cross-cutting concerns like authentication.

We will implement two types of interceptors:

  1. Request Interceptor: Attaches the current access token (stored in memory) to the Authorization header of every outgoing API request.
  2. Response Interceptor: Checks every incoming response for a 401 Unauthorized error. If detected, it orchestrates the token refresh and request-retry flow.

To see the value of this approach, let's look at the problem it solves.

A Cleaner Approach to Handling Access and Refresh Tokens

This article, 'A Cleaner Approach to Handling Access and Refresh Tokens', clearly illustrates the 'before' and 'after' of this refactoring. It shows how moving from manual error handling to a centralized interceptor cleans up the codebase.

Please read the sections 'The Problem' and 'The Refactor'. 'The Problem' shows the repetitive try/catch logic we want to avoid. 'The Refactor' presents the clean solution using a response interceptor, which is the core pattern we will be implementing today.

2. Building the Core Refresh and Interceptor Hooks

To implement this pattern cleanly in a React environment, we can create a set of custom hooks. This encapsulates the logic and makes it reusable across the application. We'll follow a structure demonstrated in the following video, which is a widely-used and robust approach.

We will need two main pieces:

  1. A useRefreshToken hook that is solely responsible for calling our refresh token server function.
  2. A useAxiosPrivate hook that returns an axios instance pre-configured with our request and response interceptors.

The video below walks through creating these exact hooks.

React Login Authentication with JWT Access, Refresh Tokens, Cookies and Axios

This video by Dave Gray provides a step-by-step guide to building a robust token refresh mechanism with Axios interceptors in React. It's one of the clearest demonstrations of this pattern.

Please watch the following two segments: Creating a useRefreshToken Hook (14:25 - 19:50): This part shows how to create a dedicated function to call the refresh endpoint. This is the first building block. Implementing Axios Interceptors (22:32 - 32:31): This is the core of our lesson. Pay close attention to how the useAxiosPrivate hook is constructed. Note the logic in the response interceptor: it checks for an error (he uses 403, but we will use 401), calls the refresh function, updates the original request's headers, and retries it. Also, notice the sent property, which is a simple but crucial flag to prevent an infinite loop of retries.

3. Adapting the Interceptor for TanStack Start

Let's consolidate the concepts from the video into a concrete implementation that fits our TanStack Start project. We'll assume you are using axios for client-side API calls to your server routes (e.g., /api/...).

First, you would have an axios instance and your refresh function, which could be part of an auth context.

// src/lib/axios.ts
import axios from 'axios';

export const api = axios.create({
  baseURL: '/', // Assuming API routes are on the same domain
  withCredentials: true // Important for sending the httpOnly refresh token cookie
});

// src/hooks/useRefreshToken.ts
import { useAuth } from './useAuth'; // Your auth context hook
import { api } from '../lib/axios';

export const useRefreshToken = () => {
  const { setAuth } = useAuth();

  const refresh = async () => {
    const response = await api.post('/api/auth/refresh'); // Our server function
    const newAccessToken = response.data.accessToken;
    
    // Update the auth state in your context
    setAuth(prev => ({ ...prev, accessToken: newAccessToken }));
    
    return newAccessToken;
  };
  return refresh;
};

Now, we can create our main hook, useAxiosPrivate, which injects the interceptors. This hook will be used by any component that needs to make an authenticated API call.

// src/hooks/useAxiosPrivate.ts
import { useEffect } from 'react';
import { api } from '../lib/axios';
import { useRefreshToken } from './useRefreshToken';
import { useAuth } from './useAuth';

export const useAxiosPrivate = () => {
  const refresh = useRefreshToken();
  const { auth } = useAuth();

  useEffect(() => {
    // Request Interceptor: Add the access token to the headers
    const requestIntercept = api.interceptors.request.use(
      config => {
        if (!config.headers['Authorization']) {
          config.headers['Authorization'] = `Bearer ${auth?.accessToken}`;
        }
        return config;
      },
      error => Promise.reject(error)
    );

    // Response Interceptor: Handle expired tokens
    const responseIntercept = api.interceptors.response.use(
      response => response,
      async (error) => {
        const originalRequest = error.config;
        
        // Check for 401 and that we haven't already retried this request
        if (error.response?.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true; // Mark as retried
          try {
            const newAccessToken = await refresh();
            originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
            return api(originalRequest); // Retry the original request
          } catch (refreshError) {
            // If refresh fails, the user should be logged out.
            // This would be handled by your auth context, e.g., by setting user to null.
            console.error("Refresh token failed", refreshError);
            return Promise.reject(refreshError);
          }
        }
        return Promise.reject(error);
      }
    );

    // Cleanup function to eject interceptors when the component unmounts
    return () => {
      api.interceptors.request.eject(requestIntercept);
      api.interceptors.response.eject(responseIntercept);
    };
  }, [auth, refresh]);

  return api;
};

4. Using the Interceptor and Handling Failures

With the useAxiosPrivate hook ready, using it is straightforward. In any component that needs to fetch protected data, you simply call the hook to get the configured axios instance.

The final piece is to see it in action and consider the ultimate failure case: what if the refresh token itself has expired? In that scenario, the refresh() call will fail, and the catch block in our response interceptor will be executed. This is our signal to log the user out and redirect them to the sign-in page.

React Login Authentication with JWT Access, Refresh Tokens, Cookies and Axios

Let's watch the final parts of the video, which demonstrate how to use the hook and how the application behaves when both the access and refresh tokens expire.

Watch the last two segments: Integrating and Testing (32:31 - 35:21): See how useAxiosPrivate replaces the standard axios import in a component. The console output clearly shows the 403 (our 401) error, the refresh process, and the successful retry. Handling Refresh Token Expiration (35:21 - 40:00): This demonstrates the complete logout flow when the refresh token is no longer valid, redirecting the user back to the login page.

When the refresh fails, you would typically clear your authentication state and use TanStack Router's useNavigate hook to redirect the user, preserving the URL they were trying to access so they can be returned there after logging back in.

Conclusion

You have now built a complete, automated token refresh system on the client side. This is a critical pattern for modern web applications that provides both strong security and a seamless user experience.

Key Takeaways:

  • Centralized Logic: Axios interceptors are the ideal place to centralize authentication logic, avoiding code duplication.
  • Request Interceptor: Ensures every authenticated request automatically includes the current access token.
  • Response Interceptor: Detects 401 Unauthorized errors, triggers a token refresh, and retries the original request.
  • Preventing Infinite Loops: A flag (e.g., _retry or sent) on the request config is essential to ensure a request is only retried once.
  • Handling Refresh Failure: If the refresh token itself is invalid, the interceptor should handle this gracefully by logging the user out.

What's Next?

Our current solution works perfectly for a single failed request or a series of sequential requests. However, in a complex application, multiple API calls might be fired simultaneously (e.g., on page load). If the access token is expired, all of them will fail and trigger a refresh. This is inefficient.

In our next lesson, we will enhance our interceptor to manage concurrent client-side requests that occur during a token refresh, ensuring the refresh operation happens only once and all pending requests are retried upon its completion.

Can't find a good explanation? Sign up and we'll make it for you

Sign up