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:
Next, we define a useApi hook, which abstracts how requests are sent to our app server, both protected and unprotected:
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:
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.
Next, the logout function is straightforward: it simply removes the access and refresh tokens from local storage.
An essential method to define is refreshTokens, which has a simple logic similar to the login function:
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:
Now, we update the refreshTokens method to include debouncing:
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:
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:
Our hook is now complete, this is the full version of it:
Implementing the Auth Provider component
Now let's have a look at our auth provider component, responsible with our global auth state.
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:
Wrapping Your App with AuthProvider
Ensure your application is wrapped with the AuthProvider to provide authentication state to all components.
Next, we’ll define a useUserApi hook to handle API methods related to user operations:
Note how we are using the sendAuthGuardedRequest method from the auth context.
Now, let's take a look at our App component.
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:
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!