Custom JWT Authorization in React

There are plenty of articles and tutorials online about how to handle JWT authorization, but many of them - even popular ones - do it wrong or overcomplicate things! Since I've implemented authorization in a few real projects with great results, I decided to share my approach. I hope it helps you simplify and better understand how to handle this process.
In this guide, I'll be using React 18 and latest React Router v6 because Router v7 is still quite new. I want to provide an example that's already been battle-tested, but the same principles apply to the latest v7 Router. For this to work, I’ll assume you already have a back-end server that sends JWT (like access and refresh tokens) via a REST API. This article focuses on the client side, as that’s where my expertise lies.
I will provide code examples with explanations, and if I don't explain a part, it's likely that I'll do so later when it makes more sense. I'll provide full file examples to make it clearer and easier to see the full picture.
Fetch Instances and Utils
We'll define Axios instances for the fetch requests, as well as utilities to interact with cookies, etc.
Cookies Utils
Cookies are more secure than local storage for JWT authentication because they offer several protections, such as the HttpOnly flag, which prevents JavaScript access and protects against XSS attacks, and the Secure flag, ensuring transmission only over HTTPS. The SameSite attribute helps prevent CSRF attacks by limiting cookies to same-site requests. Cookies are also automatically sent with each HTTP request, simplifying token management, while local storage is vulnerable to XSS because it can be accessed by JavaScript. Additionally, cookies provide better session management with configurable expiration, offering more control over token validity.
// src/utils/authUtils.ts
import Cookies from 'universal-cookie';
import { jwtDecode } from 'jwt-decode';
import { RECORD_PREFIX } from '@/config';
export function setAccessTokenCookie({
accessToken,
maxAge,
}: {
accessToken: string;
maxAge: number;
}) {
cookies.set(`${RECORD_PREFIX}accessToken`, accessToken, {
path: '/',
secure: true,
sameSite: 'strict',
maxAge: maxAge,
});
}
export function setRefreshTokenCookie({
refreshToken,
maxAge,
}: {
refreshToken: string;
maxAge: number;
}) {
cookies.set(`${RECORD_PREFIX}refreshToken`, refreshToken, {
path: '/',
secure: true,
sameSite: 'strict',
maxAge: maxAge,
});
}
export function getAccessTokenCookie(): string | undefined {
return cookies.get(`${RECORD_PREFIX}accessToken`);
}
export function getRefreshTokenCookie(): string | undefined {
return cookies.get(`${RECORD_PREFIX}refreshToken`);
}
export function removeAuthCookies() {
cookies.remove(`${RECORD_PREFIX}accessToken`, {
path: '/',
secure: true,
sameSite: 'strict',
});
cookies.remove(`${RECORD_PREFIX}refreshToken`, {
path: '/',
secure: true,
sameSite: 'strict',
});
}
export function isValidToken(token: string | null | undefined) {
if (!token) return false;
try {
const decoded = jwtDecode(token);
if (!decoded.exp) {
console.error('Token is missing the "exp" field.');
return false;
}
const currentTime = Math.floor(Date.now() / 1000);
const isNearExpired = decoded.exp - currentTime <= 120;
return !isNearExpired;
} catch (error) {
console.error('Error decoding token:', error);
return false;
}
}
Cookies Attributes:
cookies.set(${RECORD_PREFIX}accessToken, accessToken, {...});
cookies.set
: Sets a cookie in the browser.${RECORD_PREFIX}accessToken
: The name of the cookie, dynamically prefixed withRECORD_PREFIX
(e.g., "app_accessToken").accessToken
: The value of the cookie, which is the JWT access token.
path: '/'
- Defines the scope of the cookie. The cookie is available across the entire site.
secure: true
- Ensures the cookie is only sent over HTTPS connections, improving security.
sameSite: 'strict'
- Prevents the cookie from being sent with cross-site requests, adding protection against cross-site request forgery (CSRF).
maxAge: maxAge
- Sets the cookie's expiration time in seconds. The cookie will expire after this period.
Each line is there to enhance security and control how the access token is stored and used in the browser.
isValidToken Util
Here, we use jwt-decode
to check if the token has an expiration field and to determine how much time is left before it expires. We also detect expiration 2 minutes in advance so that we can refresh the token ahead of time. We won't perform additional validity checks, such as secret key verification, because those are handled by the back-end, and it's not secure to store such credentials on the client side.
Axios Instances
// src/apis/apiInstance.ts
import axios from 'axios';
import { handleRefreshToken } from './refreshTokenApi';
import { getAccessTokenCookie } from '@/utils/authUtils';
const api = axios.create({
baseURL: import.meta.env.VITE_API_DOMAIN,
});
api.interceptors.request.use(
(config) => {
const accessToken = getAccessTokenCookie();
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Handle errors globally
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response && error.response.status === 401) {
handleRefreshToken(error);
}
return Promise.reject(error);
}
);
export default api;
This code creates an axios instance with a base URL from an environment variable. It sets up two interceptors: a request interceptor that adds an Authorization header with a Bearer token (if available) before each request, and a response interceptor that handles errors globally. If the response status is 401 (unauthorized), it triggers a refresh token function to handle token expiration. If any error occurs, it returns a rejected promise with the error.
Refresh token handling with axios interceptors works only when we receive a 401 error, indicating that the access token has likely expired. In this case, we refresh the token and retry the original request without interrupting the user's action. However, it won't trigger a refresh token on route visits when no requests are sent. I will explain more about this below."
Refresh Token Util
// src/apis/refreshTokenHandler.ts
export async function handleRefreshToken(error: AxiosError) {
const refreshToken = getRefreshTokenCookie();
if (refreshToken && isValidToken(refreshToken)) {
try {
const res = await refreshTokenApi({ refreshToken });
setAccessTokenCookie({
accessToken: res.accessToken,
maxAge: res.accessTokenTtlMs,
});
// Retry the original request with the new access token
if (error.config) {
error.config.headers['Authorization'] = `Bearer ${res.accessToken}`;
return api(error.config);
}
} catch (error) {
console.error(error);
authCleanup();
}
} else {
authCleanup();
}
}
function authCleanup() {
removeAuthCookies();
window.location.replace(`${ROUTES.auth}/${ROUTES.login}`);
}
APIs Endpoints
// src/apis/authApis.ts
import api from './apiInstance';
export async function loginApi(payload: {
email: string;
password: string;
platform: 'web';
}): Promise<{
accessToken: string;
refreshToken: string;
accessTokenTtlMs: number;
refreshTokenTtlMs: number;
}> {
const res = await api.post('/auth/login', payload);
return res.data;
}
export async function refreshTokenApi(payload: {
refreshToken: string;
}): Promise<{
accessToken: string;
accessTokenTtlMs: number;
}> {
const res = await api.post('/auth/refresh', payload);
return res.data;
}
Loaders
Now for the fun part, the key concept of making robust authorization checks is loaders. Each route can define a loader
function to provide data to the route element before it renders. As the user navigates around the app, the loaders for the next matching branch of routes will be called in parallel, and their data will be made available to components through useLoaderData. Let's see how the code looks, and then I will explain each part step by step.
Loader for a protected route
// src/loaders/protectedRouteLoader.ts
import { redirect } from 'react-router-dom';
import {
isValidToken,
removeAuthCookies,
setAccessTokenCookie,
getAccessTokenCookie,
getRefreshTokenCookie,
} from '@/utils/authUtils';
import { ROUTES } from '@/router';
import { refreshTokenApi } from '@/apis/authApis';
export async function protectedRouteLoader() {
const accessToken = getAccessTokenCookie();
const refreshToken = getRefreshTokenCookie();
if (
!isValidToken(accessToken) &&
refreshToken &&
isValidToken(refreshToken)
) {
try {
const response = await refreshTokenApi({ refreshToken });
setAccessTokenCookie({
accessToken: response.accessToken,
maxAge: response.accessTokenTtlMs,
});
return response.accessToken;
} catch (error) {
console.error(error);
removeAuthCookies();
throw redirect(`${ROUTES.auth}/${ROUTES.login}`);
}
}
if (!isValidToken(accessToken) && !isValidToken(refreshToken)) {
throw redirect(`${ROUTES.auth}/${ROUTES.login}`);
}
return { accessToken };
}
This protectedRouteLoader
function handles token validation and refresh for protected routes. It first checks if the access token is invalid but a valid refresh token exists. If so, it attempts to refresh the access token using the refreshTokenApi
function, updates the access token cookie, and returns the new access token. If refreshing fails, it clears authentication cookies and redirects the user to the login page. If both the access and refresh tokens are invalid, it redirects the user to the login page. If the access token is valid, it returns the token.
Notice that here we don't rely on hooks like
useEffect
oruseLayoutEffect
, and no component lifecycle or state updates will interfere with our checks or trigger them more than necessary. This is just a regularTypeScript
file with a function that runs before rendering a route. We also don't set tokens to state, so we only have one source of truth: our cookies. This simplifies management, and everything works smoothly, making it easy to read and maintain.
I hope you now see why loaders are a game changer and how many other approaches without loaders that you've seen online simply don't look as good or robust compared to this approach with loaders. Read more about loaders in the official docs - reactrouter.com/loader
Loader for a non protected route
This is just in case we don't want to be able to go to the login route when we are already authorized.
import { redirect } from 'react-router-dom';
import {
isValidToken,
getAccessTokenCookie,
getRefreshTokenCookie,
} from '@/utils/authUtils';
import { ROUTES } from '@/router';
export async function nonProtectedRouteLoader() {
const accessToken = getAccessTokenCookie();
const refreshToken = getRefreshTokenCookie();
if (isValidToken(accessToken) || isValidToken(refreshToken)) {
throw redirect(`${ROUTES.taskList}`);
}
return { accessToken };
}
The nonProtectedRouteLoader
function checks if the user is already authenticated. It first retrieves the access and refresh tokens from cookies. If either token is valid, the function redirects the user to the index page because they should not access the non-protected route while authenticated. If both tokens are invalid, the loader returns the access token (which is undefined if not valid) to the route. This ensures that authenticated users cannot access routes meant for unauthenticated users, like the login page.
Routing
I'm using the createBrowserRouter
approach because it is recommended for all React Router web projects. It uses the DOM History API to update the URL and manage the history stack. It also enables the v6.4 data APIs like loaders, actions, fetchers and more. Read more about it in the official docs - reactrouter.com/routers.
This approach is key to correctly performing authorization checks before rendering a route, and I will show you how we can do that in loaders below.
// src/router.tsx
import { lazy, Suspense } from 'react';
import Loader from './components/Loader';
import { createBrowserRouter } from 'react-router-dom';
import { protectedRouteLoader } from './loaders/protectedRouteLoader';
import { nonProtectedRouteLoader } from './loaders/nonProtectedRouteLoader';
const TaskListRoute = lazy(
() => import('./routes/task/taskList')
);
const TaskDetailsRoute = lazy(
() => import('./routes/task/taskDetails')
);
const SidebarLayout = lazy(() => import('./routes/task/layout'));
const AuthLayout = lazy(() => import('./routes/auth/layout'));
const LoginRoute = lazy(() => import('./routes/auth/login'));
export const ROUTES = Object.freeze({
taskList: '/',
taskDetails: '/task',
auth: '/auth',
login: 'login',
register: 'register',
});
const router = createBrowserRouter([
{
path: ROUTES.taskList,
element: (
<Suspense fallback={<Loader />}>
<SidebarLayout />
</Suspense>
),
children: [
{
index: true,
loader: protectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<TaskListRoute />
</Suspense>
),
},
{
path: `${ROUTES.taskDetails}/:id`,
loader: protectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<TaskDetailsRoute />
</Suspense>
),
},
],
},
{
path: ROUTES.auth,
element: (
<Suspense fallback={<Loader />}>
<AuthLayout />
</Suspense>
),
children: [
{
index: true,
loader: nonProtectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<LoginRoute />
</Suspense>
),
},
{
path: ROUTES.login,
loader: nonProtectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<LoginRoute />
</Suspense>
),
},
{
path: ROUTES.register,
element: (
<Suspense fallback={<Loader />}>
<RequireNoAuth>
<RegisterRoute />
</RequireNoAuth>
</Suspense>
),
},
],
},
]);
export default router;
So, for each route that we want to protect, we need to add the loader we created above.
{
path: `${ROUTES.taskDetails}/:id`,
loader: protectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<TaskDetailsRoute />
</Suspense>
),
}
And here, for example, we add a loader for the route that we don't want to be accessible if the user is already authorized.
{
path: ROUTES.login,
loader: nonProtectedRouteLoader,
element: (
<Suspense fallback={<Loader />}>
<LoginRoute />
</Suspense>
),
},
App Config
// src/App.tsx
const queryClient = new QueryClient({});
function App() {
return (
<ThemeProvider theme={muiTheme}>
<MuiGlobalStyles />
<SnackbarProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</SnackbarProvider>
</ThemeProvider>
);
}
export default App;
Login Example
Here, I want to provide a full example of a login form to give a clear picture of how tokens are received and set in cookies initially. I’m using MUI, React Query, React Hook Form, and Zod.
// src/modules/AuthForm.tsx
export default function AuthForm() {
const navigate = useNavigate();
const { showSnackbar } = useSnackbar();
const { register, handleSubmit, formState } = useForm<FormData>({
resolver: zodResolver(authFormSchema),
});
const mutation = useMutation({
mutationFn: loginApi,
onSuccess: (data) => {
setAccessTokenCookie({
accessToken: data.accessToken,
maxAge: data.accessTokenTtlMs,
});
setRefreshTokenCookie({
refreshToken: data.refreshToken,
maxAge: data.refreshTokenTtlMs,
});
navigate(ROUTES.taskList, { replace: true });
},
onError: (error) => {
showSnackbar(extractErrorMessage(error), 'error');
},
});
const onSubmit = (data: FormData) => {
mutation.mutate({
...data,
platform: 'web',
});
};
return (
<Box
component='form'
onSubmit={handleSubmit(onSubmit)}
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 2,
margin: 'auto',
}}
>
<TextField
label='Email'
{...register('email')}
error={!!formState.errors.email}
helperText={formState.errors.email?.message}
/>
<TextField
label='Password'
type='password'
{...register('password')}
error={!!formState.errors.password}
helperText={formState.errors.password?.message}
/>
<Button
type='submit'
>
Login
</Button>
</Box>
);
}
Summary and UX
With this approach, we can implement robust JWT-based authorization and perform all necessary checks before anything is rendered on the screen. This helps avoid flickering or potentially leaking data to unauthorized users who might know certain route names, or when a token expires and needs to be refreshed. The user won't experience a redirect to the login page for a brief moment, and there won’t be any flickering. Additionally, we don’t rely on component lifecycle methods or use useEffect
or useLayoutEffect
with dependencies to trigger our checks, which could lead to inconsistencies and bugs. Instead, with the loaders approach I’ve demonstrated here, everything will work seamlessly because the code will execute exactly how and where we want it.
Axios interceptors handle the refresh token when a 401 Unauthorized error is received, indicating that the token has likely expired while on protected routes. Meanwhile, the loaders handle token refresh when visiting a route or refreshing the page, ensuring that actions which do not depend on API requests but still require token verification are covered.
As a result, the user experience remains uninterrupted, they won't be frequently redirected to the login screen, and the application will appear polished and stable for both users and developers.
Contact Me
Have an opportunity, wanna collaborate on something cool or just say hello!
Send Email