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:
- Persistent Authentication: We want the user to remain authenticated even after refreshing the page.
- Automatic Logout: The user should be logged out automatically when an API endpoint returns a 401 Unauthorized response.
- 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;
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 };
};
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();
}
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;
};
}
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();
};
}
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);
};
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;
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;
};
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;
}
};
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>;
};
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 };
};
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 };
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;
};
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>,
);
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 };
};
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;
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' },
}),
}),
Set the JWT refresh token expiration to 30 seconds:
const newRefreshToken = this.jwtService.sign(
{ sub: authUserId },
{
secret: this.configService.get('jwtRefreshSecret'),
expiresIn: '30s',
},
);
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,
);
}
Steps to Run the Demo:
Clone the Github Project:
git clone https://github.com/zenstok/react-auth-refresh-token-example
Install dependencies:
npm install
Run the App:
npm run dev
Ensure Backend is Running:
Navigate to the backend directory and run:
yarn dc up
Fill in users in the database if needed:
In a new terminal in backend directory run:
yarn dc-db-init
Log In:
Enter the following credentials on the login screen:
- Email: admin@admin.com
- Password: 1234
Test Functionality:
After logging in, you will see two buttons: Simulate 5 Concurrent Requests and Log Out.
- Open your browser's Network tab.
- Click Simulate 5 Concurrent Requests.
You will see the effect of the refresh token logic in action.
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. ✅
- 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)
very detailed! @zenstok
any example on how to use without storing JWTs in localstorage? (I think secure cookies will work)
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.
Such a detailed article. Thanks a lot man. Really appreciate this. Waiting for the part 3
Thank you! Would love to see how to implement DrizzleORM with NestJS.