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 + Redux Toolkit: JWT Authentication and Authorization
- How I Setup Redux Toolkit and RTK Query the right way
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.
-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.
-I made the access and logged_in cookies expire after 15 minutes leaving only the refresh token which will also expire after 60 minutes.
-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
.
-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.
-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.
-Here is the access token in the JSON response.
-Finally, RTK Query will re-try the initial request after the access token has been refreshed successfully.
-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.
-A 403 Forbidden error of Could not refresh access token
and RTK Query will automatically redirect the user to the login page.
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:
- Node.js + TypeScript + MongoDB: JWT Authentication
- Node.js + TypeScript + MongoDB: JWT Refresh Token
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
Nice article! thank you.
It’s not clear and would like to understand. You are not using localStorage from what I see so what is the mechanism behind the scene that make’s persisting login after a browser reload since at the part of “Persist Login with JWT Rotation”, you wrote:
———————-
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.
———————-
This is not clear after reading since you say, “I decided to ALSO send a logged_in: true cookie”. It’s like if you are using both, could you please explain this? What makes the persisting after login? Thank you 🙂
Instead of using localStorage to store the access token, I generated a logged_in cookie that has the same expiration time as the access token.
Using the logged_in cookie is one approach to refresh the access token, however, in the article, we defined a custom fetchBaseQuery to refresh the access token.
The fetchBaseQuery is similar to Axios interceptors.
The logged_in cookie is useful if you want to define a middleware to refresh the token, however, using the fetchBaseQuery approach was a lot simpler and easier.
You can read the React Query article for a detailed explanation of how to refresh the access token with middleware and Axios interceptors.