DEV Community

Cover image for Part 2/3: How to Integrate Refresh Tokens in React
zenstok
zenstok

Posted on • Updated on

Part 2/3: How to Integrate Refresh Tokens in React

Hello, guys!

On the premise that our App is immune to XSS attacks, we will store both access & refresh tokens in the local storage. For this, we will use React which escapes any values embedded in JSX before rendering them, greatly helping us in countering XSS attacks.

This is the second episode in our three-part series on implementing refresh tokens. In our previous article, we explored how to implement refresh tokens in NestJS. Be sure to check it out if you're looking to easily run the demo from this tutorial.

While storing both access and refresh tokens in local storage is convenient, it does come with security risks. Even with robust XSS attack prevention, there's still a vulnerability to attacks via third-party libraries. Fortunately, in the final episode of this series, we'll demonstrate how to securely store refresh tokens using HTTP-only cookies, which enhances security.

Implementation overview

To keep the application straightforward, we've implemented only the core features necessary to demonstrate the functionality. Here’s what we aim to achieve and some potential caveats to be aware of:

Objectives:

  1. Persistent Authentication: We want the user to remain authenticated even after refreshing the page.
  2. Automatic Logout: The user should be logged out automatically when an API endpoint returns a 401 Unauthorized response.
  3. Seamless Data Access: When calling a protected endpoint, the app should retrieve data if a valid refresh token exists. If the first request returns a 401, the app should attempt to refresh the tokens. Only if the subsequent request after refreshing also fails with a 401 should the user be logged out.

Potential Caveat:

A notable issue is that the refresh token endpoint call invalidates the previous refresh token. This can create a problem if /auth/refresh-tokens is called more than once concurrently, as it may lead to inconsistencies or failures in token handling. To mitigate this, we must ensure that the refresh token is not requested multiple times concurrently, even if multiple protected requests are made across the app.

If you want to jump directly to the GitHub repo to see how we did it, you can check it out here.

Getting started

Since the authentication state is a global concept in the app, we need to ensure that every component can access this state. The best way to manage this is by using React Context to define a global context provider. We will look into this later in the tutorial.

First, we define a class responsible for managing the manipulation of local storage keys for access and refresh tokens:

const ACCESS_TOKEN_KEY = "rabbit.byte.club.access.token";
const REFRESH_TOKEN_KEY = "rabbit.byte.club.refresh.token";

class AuthClientStore {
  static getAccessToken() {
    return localStorage.getItem(ACCESS_TOKEN_KEY);
  }

  static setAccessToken(token: string) {
    localStorage.setItem(ACCESS_TOKEN_KEY, token);
  }

  static removeAccessToken(): void {
    localStorage.removeItem(ACCESS_TOKEN_KEY);
  }

  static getRefreshToken() {
    return localStorage.getItem(REFRESH_TOKEN_KEY);
  }

  static setRefreshToken(token: string) {
    localStorage.setItem(REFRESH_TOKEN_KEY, token);
  }

  static removeRefreshToken(): void {
    localStorage.removeItem(REFRESH_TOKEN_KEY);
  }
}

export default AuthClientStore;
Enter fullscreen mode Exit fullscreen mode

Next, we define a useApi hook, which abstracts how requests are sent to our app server, both protected and unprotected:

import AuthClientStore from "../../auth/client-store/auth-client-store.ts";
import { ApiMethod } from "../types.ts";

const apiUrl = import.meta.env.VITE_API_BASE_URL as string;

const sendRequest = (
  method: ApiMethod,
  path: string,
  // eslint-disable-next-line
  body?: any,
  authToken?: string | null,
  init?: RequestInit,
) => {
  return fetch(apiUrl + path, {
    method,
    ...(body && { body: JSON.stringify(body) }),
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...(authToken && { Authorization: `Bearer ${authToken}` }),
      ...init?.headers,
    },
  }).then((response) => {
    if (response.status >= 400) {
      throw response;
    }
    return response.json();
  });
};

const sendProtectedRequest = (
  method: ApiMethod,
  path: string,
  // eslint-disable-next-line
  body?: any,
  useRefreshToken = false,
  init?: RequestInit,
) => {
  const authToken = useRefreshToken
    ? AuthClientStore.getRefreshToken()
    : AuthClientStore.getAccessToken();
  if (!authToken) {
    throw new Error("No auth token found");
  }

  return sendRequest(method, path, body, authToken, init);
};

export const useApi = () => {
  return { sendRequest, sendProtectedRequest };
};
Enter fullscreen mode Exit fullscreen mode

Please note that the sendProtectedRequest method accepts an optional useRefreshToken parameter. This is used exclusively by routes that refresh the token, as they require the refresh token for bearer authentication. In all other cases, the default behavior is to use the access token.

Now we need to define a useAuthApi hook where the magic happens.

First, we will use useApi hook from which we need to call sendRequest and sendProtectedRequest methods:

export const useAuthApi = () => {
  const { sendRequest, sendProtectedRequest } = useApi();
}
Enter fullscreen mode Exit fullscreen mode
Implementing Authentication: Login, Logout, and Token Refresh

Now, let's define the login function, which, upon successful authentication, will set the access and refresh tokens in the local storage.

export const useAuthApi = () => {
  const { sendRequest, sendProtectedRequest } = useApi();

  const login = async (email: string, password: string) => {
    const response = await sendRequest(ApiMethod.POST, routes.auth.login, {
      email,
      password,
    });

    AuthClientStore.setAccessToken(response.access_token);
    AuthClientStore.setRefreshToken(response.refresh_token);

    return response;
  };
}
Enter fullscreen mode Exit fullscreen mode

Next, the logout function is straightforward: it simply removes the access and refresh tokens from local storage.

export const useAuthApi = () => {
  const { sendRequest, sendProtectedRequest } = useApi();

  const login = async (email: string, password: string) => {
    const response = await sendRequest(ApiMethod.POST, routes.auth.login, {
      email,
      password,
    });

    AuthClientStore.setAccessToken(response.access_token);
    AuthClientStore.setRefreshToken(response.refresh_token);

    return response;
  };

  const logout = () => {
    AuthClientStore.removeAccessToken();
    AuthClientStore.removeRefreshToken();
  };
}
Enter fullscreen mode Exit fullscreen mode

An essential method to define is refreshTokens, which has a simple logic similar to the login function:

const refreshTokens = async () => {
  const response = await sendProtectedRequest(
    ApiMethod.POST,
    routes.auth.refreshTokens,
    undefined,
    AuthClientStore.getRefreshToken(),
  );

  AuthClientStore.setAccessToken(response.access_token);
  AuthClientStore.setRefreshToken(response.refresh_token);
};
Enter fullscreen mode Exit fullscreen mode

Note that because the refresh tokens endpoint requires the refresh token as an authentication method, we pass the refresh token as a parameter to sendProtectedRequest so it uses the refresh token as the bearer instead of the default access token used for other requests.

Is this all we need for the refreshTokens method? Well, keep in mind that each call to refresh the access token will invalidate the previous refresh tokens. So, if two parts of the app concurrently need to refresh the access token, one request might fail because the first request will invalidate the refresh token being used. Therefore, it's crucial to ensure this method is called only once. To manage this logic efficiently, we need to decorate this method a little bit.

Debouncing Refresh Tokens Requests and Managing Authentication State

To handle cases where multiple parts of the app might concurrently call the refresh tokens method, we need to debounce these calls so that only one request is made. We also need to ensure that each caller receives the same access and refresh token pair. Here's how we achieve this:

First, we define some variables outside of the hook to manage the debounce logic:

/*
 * These variables are used to debounce the refreshTokens function
 */
let debouncedPromise: Promise<unknown> | null = null;
let debouncedResolve: (...args: unknown[]) => void;
let debouncedReject: (...args: unknown[]) => void;
let timeout: number;
Enter fullscreen mode Exit fullscreen mode

Now, we update the refreshTokens method to include debouncing:

const refreshTokens = async () => {
  clearTimeout(timeout);
  if (!debouncedPromise) {
    debouncedPromise = new Promise((resolve, reject) => {
      debouncedResolve = resolve;
      debouncedReject = reject;
    });
  }

  timeout = setTimeout(() => {
    const executeLogic = async () => {
      const response = await sendProtectedRequest(
        ApiMethod.POST,
        routes.auth.refreshTokens,
        undefined,
        AuthClientStore.getRefreshToken(),
      );

      AuthClientStore.setAccessToken(response.access_token);
      AuthClientStore.setRefreshToken(response.refresh_token);
    };

    executeLogic().then(debouncedResolve).catch(debouncedReject);

    debouncedPromise = null;
  }, 200);

  return debouncedPromise;
};
Enter fullscreen mode Exit fullscreen mode

Here’s how it works:

  • We clear the timeout whenever a new caller invokes this method within a 200ms window, effectively debouncing the calls.
  • The debouncedPromise ensures that all callers receive the same promise, which resolves or rejects when the token refresh logic completes.
  • After processing, debouncedPromise is reset to handle new calls later.

Next, we define a method that acts as a gatekeeper for protected API routes. It attempts a request and, if it fails with a 401 (Unauthorized) error, refreshes the access token and retries the request:

const sendAuthGuardedRequest = async (
  userIsNotAuthenticatedCallback: () => void,
  method: ApiMethod,
  path: string,
  body?: any,
  init?: RequestInit,
) => {
  try {
    return await sendProtectedRequest(method, path, body, undefined, init);
  } catch (e) {
    if (e?.status === 401) {
      try {
        await refreshTokens();
      } catch (e) {
        userIsNotAuthenticatedCallback();
        throw e;
      }
      return await sendProtectedRequest(method, path, body, undefined, init);
    }

    throw e;
  }
};
Enter fullscreen mode Exit fullscreen mode

The userIsNotAuthenticatedCallback parameter allows the authentication context provider to update the global auth state, which any component in the app can listen to.

Finally, we define a method for checking if the user is authenticated by calling the /auth/me endpoint. This should be executed on app startup:

const me = (userIsNotAuthenticatedCallback: () => void) => {
  return sendAuthGuardedRequest(
    userIsNotAuthenticatedCallback,
    ApiMethod.GET,
    routes.auth.me,
  ) as Promise<User>;
};
Enter fullscreen mode Exit fullscreen mode

Our hook is now complete, this is the full version of it:

/*
 * These variables are used to debounce the refreshTokens function
 */
let debouncedPromise: Promise<unknown> | null;
let debouncedResolve: (...args: unknown[]) => void;
let debouncedReject: (...args: unknown[]) => void;
let timeout: number;

export const useAuthApi = () => {
  const { sendRequest, sendProtectedRequest } = useApi();

  const login = async (email: string, password: string) => {
    const response = await sendRequest(ApiMethod.POST, routes.auth.login, {
      email,
      password,
    });

    AuthClientStore.setAccessToken(response.access_token);
    AuthClientStore.setRefreshToken(response.refresh_token);

    return response;
  };

  const logout = () => {
    AuthClientStore.removeAccessToken();
    AuthClientStore.removeRefreshToken();
  };
  const refreshTokens = async () => {
    clearTimeout(timeout);
    if (!debouncedPromise) {
      debouncedPromise = new Promise((resolve, reject) => {
        debouncedResolve = resolve;
        debouncedReject = reject;
      });
    }

    timeout = setTimeout(() => {
      const executeLogic = async () => {
        const response = await sendProtectedRequest(
          ApiMethod.POST,
          routes.auth.refreshTokens,
          undefined,
          AuthClientStore.getRefreshToken(),
        );

        AuthClientStore.setAccessToken(response.access_token);
        AuthClientStore.setRefreshToken(response.refresh_token);
      };

      executeLogic().then(debouncedResolve).catch(debouncedReject);

      debouncedPromise = null;
    }, 200);

    return debouncedPromise;
  };

  const sendAuthGuardedRequest = async (
    userIsNotAuthenticatedCallback: () => void,
    method: ApiMethod,
    path: string,
    // eslint-disable-next-line
    body?: any,
    init?: RequestInit,
  ) => {
    try {
      return await sendProtectedRequest(method, path, body, undefined, init);
    } catch (e) {
      if (e?.status === 401) {
        try {
          await refreshTokens();
        } catch (e) {
          userIsNotAuthenticatedCallback();
          throw e;
        }
        return await sendProtectedRequest(method, path, body, undefined, init);
      }

      throw e;
    }
  };

  const me = (userIsNotAuthenticatedCallback: () => void) => {
    return sendAuthGuardedRequest(
      userIsNotAuthenticatedCallback,
      ApiMethod.GET,
      routes.auth.me,
    ) as Promise<User>;
  };

  return { login, logout, me, sendAuthGuardedRequest };
};

Enter fullscreen mode Exit fullscreen mode
Implementing the Auth Provider component

Now let's have a look at our auth provider component, responsible with our global auth state.

type ContextType = {
  isAuthenticated: boolean;
  login(email: string, password: string): Promise<void>;
  logout(): void;
  me(): Promise<User>;
  sendAuthGuardedRequest(
    method: ApiMethod,
    path: string,
    // eslint-disable-next-line
    body?: any,
    init?: RequestInit,
  ): Promise<unknown>;
};

const AuthContext = createContext<ContextType | undefined>(undefined);

function AuthProvider({ children }: { children: ReactNode }) {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const {
    login: authLogin,
    logout: authLogout,
    me: authMe,
    sendAuthGuardedRequest: authSendAuthGuardedRequest,
  } = useAuthApi();

  const login = async (email: string, password: string) => {
    try {
      await authLogin(email, password);
      setIsAuthenticated(true);
    } catch (e) {
      setIsAuthenticated(false);
      throw e;
    }
  };

  const logout = () => {
    authLogout();
    setIsAuthenticated(false);
  };

  const me = async () => {
    const user = await authMe(() => {
      setIsAuthenticated(false);
    });
    setIsAuthenticated(true);

    return user;
  };

  const sendAuthGuardedRequest = async (
    method: ApiMethod,
    path: string,
    // eslint-disable-next-line
    body?: any,
    init?: RequestInit,
  ) => {
    return authSendAuthGuardedRequest(
      () => {
        setIsAuthenticated(false);
      },
      method,
      path,
      body,
      init,
    );
  };

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        login,
        logout,
        me,
        sendAuthGuardedRequest,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

export { AuthProvider, AuthContext };
Enter fullscreen mode Exit fullscreen mode

In this implementation, we’ve essentially wrapped the authentication API hook methods and manage the isAuthenticated state based on API responses. The final method in this component is very important: it is used by all other hooks or components that need to perform protected requests to our API. By incorporating the userIsNotAuthenticated callback, we ensure that when an endpoint call fails due to token expiration, the authentication state is updated. This approach allows the isAuthenticated state to be set to false, prompting all components across the app to adjust their behavior accordingly.

Next, we’ll define a useAuthContext hook to simplify access to the authentication state:

export const useAuthContext = () => {
  const ctx = useContext(AuthContext);

  if (!ctx) {
    throw new Error("useAuthContext must be within AuthProvider");
  }

  return ctx;
};
Enter fullscreen mode Exit fullscreen mode
Wrapping Your App with AuthProvider

Ensure your application is wrapped with the AuthProvider to provide authentication state to all components.

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <AuthProvider>
      <App />
    </AuthProvider>
  </React.StrictMode>,
);

Enter fullscreen mode Exit fullscreen mode

Next, we’ll define a useUserApi hook to handle API methods related to user operations:

export const useUserApi = () => {
  const { sendAuthGuardedRequest } = useAuthContext();

  const findAllUsers = async (
    limit: number,
    offset: number,
  ): Promise<FindAllUsersResponse> => {
    const queryString = buildQueryParams([
      { key: "limit", value: limit.toString() },
      { key: "offset", value: offset.toString() },
    ]);

    return sendAuthGuardedRequest(
      ApiMethod.GET,
      routes.user.findAll + queryString,
    );
  };

  const findOneUser = async (id: number): Promise<User> => {
    return sendAuthGuardedRequest(ApiMethod.GET, routes.user.findOne(id));
  };

  return { findAllUsers, findOneUser };
};
Enter fullscreen mode Exit fullscreen mode

Note how we are using the sendAuthGuardedRequest method from the auth context.

Now, let's take a look at our App component.

function App() {
  const { isAuthenticated, login, logout, me } = useAuthContext();
  const [appIsLoading, setAppIsLoading] = useState(true);

  const { findAllUsers } = useUserApi();

  useEffect(() => {
    me()
      .catch(() => {})
      .finally(() => setAppIsLoading(false));
  }, []);

  if (appIsLoading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return (
      <form
        style={ display: "flex", flexDirection: "column", gap: 16 }
        onSubmit={(e) => {
          e.preventDefault();
          login(e.target[0].value, e.target[1].value);
        }}
      >
        <div>Authentication</div>
        <input placeholder="Email" />
        <input placeholder="Password" type="password" />
        <button type="submit">Login</button>
      </form>
    );
  }

  return (
    <>
      <div>
        <a href="https://rabbitbyte.club" target="_blank">
          <img
            src={rabitByteClubLogo}
            className="logo"
            alt="logo-rabbit-byte"
          />
        </a>
      </div>
      <h1>Rabbit Byte Club</h1>
      <div style={{ display: "flex", gap: 16 }}>
        <button
          onClick={() => {
            for (let i = 0; i < 5; i++) {
              findAllUsers(10, 0);
            }
          }}
        >
          Simulate 5 concurrent requests
        </button>
        <button onClick={() => logout()}>Logout</button>
      </div>
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

We define an appLoading state, which is set to false once the /auth/me endpoint completes, regardless of success or failure. While the user is not authenticated, we display the login form.

If the user is authenticated, a button is provided to simulate 5 concurrent requests, allowing you to test the logic easily in the demo.

DEMO

Prerequisites:

To test the feature quickly and easily, set the JWT access token expiration to 10 seconds in your backend application:

JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService<EnvironmentVariables>) => ({
    secret: configService.get('jwtSecret'),
    signOptions: { expiresIn: '10s' },
  }),
}),
Enter fullscreen mode Exit fullscreen mode

Set the JWT refresh token expiration to 30 seconds:

    const newRefreshToken = this.jwtService.sign(
      { sub: authUserId },
      {
        secret: this.configService.get('jwtRefreshSecret'),
        expiresIn: '30s',
      },
    );
Enter fullscreen mode Exit fullscreen mode

Additionally, you may want to temporarily disable throttling on the refresh tokens endpoint:

// @Throttle({
//   short: { limit: 1, ttl: 1000 },
//   long: { limit: 2, ttl: 60000 },
// })
@ApiBearerAuth()
@Public()
@UseGuards(JwtRefreshAuthGuard)
@Post('refresh-tokens')
refreshTokens(@Request() req: ExpressRequest) {
  if (!req.user) {
    throw new InternalServerErrorException();
  }
  return this.authRefreshTokenService.generateTokenPair(
    (req.user as any).attributes,
    req.headers.authorization?.split(' ')[1],
    (req.user as any).refreshTokenExpiresAt,
  );
}
Enter fullscreen mode Exit fullscreen mode

Steps to Run the Demo:

Clone the Github Project:

   git clone https://github.com/zenstok/react-auth-refresh-token-example
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

   npm install
Enter fullscreen mode Exit fullscreen mode

Run the App:

   npm run dev
Enter fullscreen mode Exit fullscreen mode

Ensure Backend is Running:

Navigate to the backend directory and run:

   yarn dc up
Enter fullscreen mode Exit fullscreen mode

Fill in users in the database if needed:

In a new terminal in backend directory run:

   yarn dc-db-init
Enter fullscreen mode Exit fullscreen mode

Log In:

Enter the following credentials on the login screen:

Login Screen

Test Functionality:

After logging in, you will see two buttons: Simulate 5 Concurrent Requests and Log Out.

Buttons

  • Open your browser's Network tab.
  • Click Simulate 5 Concurrent Requests.

You will see the effect of the refresh token logic in action.

Network Tab

The app will wait for a single call to the refresh tokens endpoint and then rerun the requests. Success!

Verification of Objectives:

  • Persistent Authentication: Refresh the page and ensure you remain authenticated. ✅
  • Automatic Logout: Log in and wait for more than 30 seconds. After pressing Simulate 5 Concurrent Requests, confirm that you are logged out. ✅

Logout Verification

  • Seamless Data Access: When calling a protected endpoint, the app should return data if a valid refresh token exists. ✅

Conclusion

I hope this tutorial has been helpful in your journey to implement refresh tokens in React. Stay tuned for the final episode of this series, where we'll swap the backend and frontend logic to use HTTP-only cookies for the refresh token.

If you'd like me to cover more interesting topics about the node.js ecosystem, feel free to leave your suggestions in the comments section. Don't forget to subscribe to my newsletter on rabbitbyte.club for updates!

Post Creation:
Check out Part 3 of this series, where we update the app to use HTTP-only cookies for refresh tokens.

Top comments (4)

Collapse
 
orel_hindi_5117829f0affe1 profile image
Orel Hindi • Edited

very detailed! @zenstok
any example on how to use without storing JWTs in localstorage? (I think secure cookies will work)

Collapse
 
zenstok profile image
zenstok

Hey @orel_hindi_5117829f0affe1, I've published part 3 which implements the logic of storing the refresh token in a HTTP-Only Cookie. Check it out here.

Collapse
 
leo8545 profile image
Sharjeel Ahmad

Such a detailed article. Thanks a lot man. Really appreciate this. Waiting for the part 3

Collapse
 
mniedbalec profile image
Marcin Niedbalec (bełk0cik') • Edited

Thank you! Would love to see how to implement DrizzleORM with NestJS.