In this article, you’ll learn how to refresh a JWT access token and persist a user login with Redux Toolkit, RTK Query, and React.js.

Related Posts:

React.js + Redux Toolkit Refresh Tokens Authentication

React.j + Redux Toolkit + RTK Query Refresh Tokens Overview

-First, when the user logs into his account the server will send three cookies to the user’s browser.

jwt refresh access token login user

-Here are the three cookies sent by the server after a successful login. The access and refresh token cookies are HTTPOnly but the logged_in is not.

Doing it this way will allow us to access the logged_in cookie in the React application since it’s not an HTTPOnly cookie. Also, it has the same expiration time as the access token.

jwt refresh access token cookies in the browser

-I made the access and logged_in cookies expire after 15 minutes leaving only the refresh token which will also expire after 60 minutes.

jwt refresh access token expired cookies in the browser

-When only the refresh token is available and a request is made to a protected route on the server, the server will respond with a 401 Unauthorized error.

Next, RTK Query will receive that error and make another request to refresh the access token if the error message is You are not logged in .

jwt refresh access token when user is unauthorized request headers

-Here is the 401 Unauthorized error message You are not logged in that will trigger RTK Query to make an additional request (/api/auth/refresh) to refresh the access token.

jwt refresh token first request when user is not logged in

-When the server endpoint api/auth/refresh is hit, the server will then validate the refresh token and check if the user has a valid session in the Redis database before sending back a new access token as a cookie and JSON response.

jwt refresh access token cookies in the browser request headers

-Here is the access token in the JSON response.

jwt refresh access token when user is unauthorized

-Finally, RTK Query will re-try the initial request after the access token has been refreshed successfully.

rtk query refresh access token after unauthorization

-If both the access and refresh cookies have expired and a request is made by RTK Query to a protected route, the server will then return a 403 Forbidden error.

jwt authentication could not refresh access token

-A 403 Forbidden error of Could not refresh access token and RTK Query will automatically redirect the user to the login page.

jwt authentication could not refresh access token response

Backend For Refreshing the Access Token

The backend logic for refreshing an access token in this article is built with Node.js. The access token can only be refreshed after every 15 minutes within a valid session (60 minutes).

Please read Node.js + TypeScript + MongoDB: JWT Refresh Token for more details.

src/controllers/auth.controller.ts


export const refreshAccessTokenHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = req.cookies.refresh_token as string;

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    const message = 'Could not refresh access token';
    if (!decoded) {
      return next(new AppError(message, 403));
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);
    if (!session) {
      return next(new AppError(message, 403));
    }

    // Check if the user exist
    const user = await findUserById(JSON.parse(session)._id);

    if (!user) {
      return next(new AppError(message, 403));
    }

    // Sign new access token
    const access_token = signJwt({ sub: user._id }, 'accessTokenPrivateKey', {
      expiresIn: `${config.get<number>('accessTokenExpiresIn')}m`,
    });

    // Send the access token as cookie
    res.cookie('access_token', access_token, accessTokenCookieOptions);
    res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send response
    res.status(200).json({
      status: 'success',
      access_token,
    });
  } catch (err: any) {
    next(err);
  }
};

Let me breakdown what I did above:

  • First, I got the refresh token from the req.cookies object and validated it.
  • Next, I checked if the user exist and has a valid session. I made the session last for 60 minutes.
  • If the user exists and the session is valid then send back a new access token.

Read more about JWT Authentication and Authorization on the Backend:

Refresh Tokens with RTK Query Custom BaseQuery

Create a custom baseQuery that wraps fetchBaseQuery such that when a 401 Unauthorized error is encountered when a query or mutation is made to the backend, an additional request is made to refresh the JWT access token and re-try the initial query or mutation after re-authentication.

On my backend, there were several scenarios where I replied with a 401 Unauthorized error so what I did was send a specific message You are not logged in when the user is not logged in.

Instead of checking for a 401 unauthorized status code, I checked if the error message returned by the backend is You are not logged in before trying to refresh an access token.

I also used async-mutex to prevent multiple requests to /api/auth/refresh endpoint when the first request to refresh the access token fails.

The user will be immediately logged out and sent to the login page when the request to refresh the access token fails.

src/redux/api/customFetchBase.ts


import {
  BaseQueryFn,
  FetchArgs,
  fetchBaseQuery,
  FetchBaseQueryError,
} from '@reduxjs/toolkit/query';
import { Mutex } from 'async-mutex';
import { logout } from '../features/userSlice';

const baseUrl = `${process.env.REACT_APP_SERVER_ENDPOINT}/api/`;

// Create a new mutex
const mutex = new Mutex();

const baseQuery = fetchBaseQuery({
  baseUrl,
});

const customFetchBase: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  // wait until the mutex is available without locking it
  await mutex.waitForUnlock();
  let result = await baseQuery(args, api, extraOptions);
  if ((result.error?.data as any)?.message === 'You are not logged in') {
    if (!mutex.isLocked()) {
      const release = await mutex.acquire();

      try {
        const refreshResult = await baseQuery(
          { credentials: 'include', url: 'auth/refresh' },
          api,
          extraOptions
        );

        if (refreshResult.data) {
          // Retry the initial query
          result = await baseQuery(args, api, extraOptions);
        } else {
          api.dispatch(logout());
          window.location.href = '/login';
        }
      } finally {
        // release must be called once the mutex should be released again.
        release();
      }
    } else {
      // wait until the mutex is available without locking it
      await mutex.waitForUnlock();
      result = await baseQuery(args, api, extraOptions);
    }
  }

  return result;
};

export default customFetchBase;


RTK Query Mutations and Queries Endpoints

I didn’t want to include the RTK Query mutations and queries but I thought of it twice because I know it might be helpful to someone.

Below are the mutations endpoints to register a new user, log in the registered user and also log out the user on the server.

Note: You need to set credentials: 'include' if you want to send and receive cookies from the server.

src/redux/api/authApi.ts


import { createApi } from '@reduxjs/toolkit/query/react';
import { LoginInput } from '../../pages/login.page';
import { RegisterInput } from '../../pages/register.page';
import customFetchBase from './customFetchBase';
import { IUser } from './types';
import { userApi } from './userApi';

export const authApi = createApi({
  reducerPath: 'authApi',
  baseQuery: customFetchBase,
  endpoints: (builder) => ({
    registerUser: builder.mutation<IUser, RegisterInput>({
      query(data) {
        return {
          url: 'auth/register',
          method: 'POST',
          body: data,
        };
      },
      transformResponse: (result: { data: { user: IUser } }) =>
        result.data.user,
    }),
    loginUser: builder.mutation<
      { access_token: string; status: string },
      LoginInput
    >({
      query(data) {
        return {
          url: 'auth/login',
          method: 'POST',
          body: data,
          credentials: 'include',
        };
      },
      async onQueryStarted(args, { dispatch, queryFulfilled }) {
        try {
          await queryFulfilled;
          await dispatch(userApi.endpoints.getMe.initiate(null));
        } catch (error) {}
      },
    }),
    logoutUser: builder.mutation<void, void>({
      query() {
        return {
          url: 'auth/logout',
          credentials: 'include',
        };
      },
    }),
  }),
});

export const {
  useLoginUserMutation,
  useRegisterUserMutation,
  useLogoutUserMutation,
} = authApi;


Below is the query that will be immediately evoked to get the currently logged-in user’s credentials after a successful login.

src/redux/api/userApi.ts


import { createApi } from '@reduxjs/toolkit/query/react';
import { setUser } from '../features/userSlice';
import customFetchBase from './customFetchBase';
import { IUser } from './types';

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: customFetchBase,
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getMe: builder.query<IUser, null>({
      query() {
        return {
          url: 'users/me',
          credentials: 'include',
        };
      },
      transformResponse: (result: { data: { user: IUser } }) =>
        result.data.user,
      async onQueryStarted(args, { dispatch, queryFulfilled }) {
        try {
          const { data } = await queryFulfilled;
          dispatch(setUser(data));
        } catch (error) {}
      },
    }),
  }),
});


Persist Login with JWT Rotation

Persisting a user login with JWT access and refresh tokens can be a little challenging.

I watched a lot of videos and read so many articles but what I realized was most of them were storing the access token or the user’s information in localStorage in order to persist the login after a browser reload.

I decided to also send a logged_in: true cookie in addition to the access and refresh token cookies to the user’s browser.

The logged_in cookie is needed for the frontend to know whether the user is logged in since the access and refresh token cookies are HTTPOnly cookies.

Middleware to Refresh Access Token on Protected Pages

This middleware will be evoked when the user visits a protected route (page).

In this middleware, a request to get the user’s credentials will be fired, and when it fails with a 401 Unauthorized status code and error message You are not logged in then RTK Query will try to refresh the access token.

RTK Query will then re-try to get the user’s credentials again after the refresh access token request is successful.

src/components/requireUser.ts


import { useCookies } from 'react-cookie';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { userApi } from '../redux/api/userApi';
import FullScreenLoader from './FullScreenLoader';

const RequireUser = ({ allowedRoles }: { allowedRoles: string[] }) => {
  const [cookies] = useCookies(['logged_in']);
  const location = useLocation();

  const { isLoading, isFetching } = userApi.endpoints.getMe.useQuery(null, {
    skip: false,
    refetchOnMountOrArgChange: true,
  });

  const loading = isLoading || isFetching;

  const user = userApi.endpoints.getMe.useQueryState(null, {
    selectFromResult: ({ data }) => data,
  });

  if (loading) {
    return <FullScreenLoader />;
  }

  return (cookies.logged_in || user) &&
    allowedRoles.includes(user?.role as string) ? (
    <Outlet />
  ) : cookies.logged_in && user ? (
    <Navigate to='/unauthorized' state={{ from: location }} replace />
  ) : (
    <Navigate to='/login' state={{ from: location }} replace />
  );
};

export default RequireUser;


Here is what I did above:

Middleware to Refresh Access Token on Unprotected Pages

This middleware will be evoked to refresh the access token when the user reloads the browser on unprotected pages.

src/helpers/AuthMiddleware.tsx


import React from 'react';
import { useCookies } from 'react-cookie';
import FullScreenLoader from '../components/FullScreenLoader';
import { userApi } from '../redux/api/userApi';

type IAuthMiddleware = {
  children: React.ReactElement;
};

const AuthMiddleware: React.FC<IAuthMiddleware> = ({ children }) => {
  const [cookies] = useCookies(['logged_in']);

  const { isLoading } = userApi.endpoints.getMe.useQuery(null, {
    skip: !cookies.logged_in,
  });

  console.log('From middleware: ', cookies.logged_in);

  if (isLoading) {
    return <FullScreenLoader />;
  }

  return children;
};

export default AuthMiddleware;


Logic in Index.tsx

Finally, you need to wrap the AuthMiddleware middleware around the root <App/> so that it will be called when the user reloads the browser.

src/index.tsx


import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import { CookiesProvider } from 'react-cookie';
import App from './App';
import AuthMiddleware from './Helpers/AuthMiddleware';
import { store } from './redux/store';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <Router>
        <CookiesProvider>
          <AuthMiddleware>
            <App />
          </AuthMiddleware>
        </CookiesProvider>
      </Router>
    </Provider>
  </React.StrictMode>
);


Conclusion

In this article, you learned how to refresh an access token and persist user login with React.js, Redux Toolkit, and RTK Query.

Source code on GitHub