React Authentication Best Practices: The 2026 Checklist
A React application can have authentication working correctly and still have important security problems around it.
The login redirects correctly. The user comes back signed in. API calls succeed. Logout appears to work. From a functional point of view, everything looks fine.
The issues usually sit around those successful flows: where tokens are stored, what happens when they expire, whether the API actually enforces authorization, how browser-side attacks affect the session, and whether logout really ends what you think it ends.
In this post, I will go through one architecture decision and 19 things worth checking in a React application. Most of them can be verified using browser developer tools or a few direct API calls.
Start with the architecture
Before looking at individual authentication settings, start with one question: where does authentication actually run in your application, and where do the tokens end up?
For a React application, there are three common ways to build this.
- Your React app handles authentication and keeps the tokens in the browser.
- Your backend handles the login flow, but React still receives an access token and calls APIs directly.
- Your backend handles authentication and keeps all the tokens, so React never receives one.
There is no single answer that fits every application. The important part is understanding what each option protects you from and what responsibility it adds.
One distinction is useful before going further: stealing a token and using an already authenticated browser are different problems.
If an attacker steals a token, they can take that credential somewhere else and use it independently of your application.
If malicious JavaScript is already running inside your page, it may not need to steal anything. It can use the authenticated application while the user is still signed in.
Where you keep the token makes a big difference to the first problem. It does not automatically solve the second.
Option 1: React holds the tokens
This is the model most React authentication tutorials start with.
Your application redirects the user to sign in, receives tokens after authentication, keeps them somewhere in the browser, and attaches the access token to API calls.
It is also the simplest option when you have no backend of your own.
The trade-off is that the authentication credentials are now part of the browser environment. If malicious JavaScript runs inside your application, it may be able to read the tokens, obtain new ones, or make authenticated API calls.
That does not mean this architecture is always wrong. It means that if you use it, decisions such as token storage, token lifetime, refresh-token rotation, and browser security become much more important.
Option 2: Your backend handles login, React still calls the APIs
The next option is to move the login flow to your backend.
Instead of React exchanging the authorization code and holding the long-lived authentication state, your backend does that work. It can keep the refresh token on the server and give React only the access token it needs to call APIs.
This removes the longer-lived credential from the browser, which is a useful improvement.
However, React still receives an access token. If malicious JavaScript runs inside the page, that access token can still be exposed or used.
This can be a practical middle ground when you already have a backend but do not want every API request to go through it.
Option 3: React never receives a token
The third option is to keep all OAuth tokens on the backend.
React authenticates through your backend and receives a normal browser session, usually represented by a secure cookie. When the application needs data, it calls your backend, and the backend calls the API using the access token.
From the React application's point of view, there is no OAuth token to store or attach to requests.
This significantly reduces the risk of a token being extracted from frontend code because the token never reaches the frontend in the first place.
It does not solve every browser attack. If malicious JavaScript is running inside the application, it may still be able to make requests through the authenticated backend while the user's session is active.
There are also costs. Your backend is now in the request path, you have another component to operate on, and cookie-based sessions mean you need to think about CSRF protection as well.
For applications dealing with sensitive business or personal data, this is usually the option I would evaluate first.
If you use Next.js or another React framework with server-side capabilities, implementing this may be easier because you already have somewhere to run the backend part. A purely static deployment is different, so having a Next.js application does not automatically mean you already have this architecture.
Which one should you choose?
If you have no backend, option 1 may be the practical choice. In that case, the rest of this checklist becomes particularly important.
If you already have a backend, option 2 removes some of the more sensitive authentication state from the browser without changing the application architecture too much.
If you want OAuth tokens completely outside the React application, option 3 provides the strongest separation, with the additional backend and session-management work that comes with it.
These three architectures are also described in the current OAuth guidance for browser-based applications, including RFC 10017, OAuth 2.0 for Browser-Based Applications. The terminology used there is browser-based OAuth client, token-mediating backend, and Backend for Frontend (BFF).
For the rest of this article, I will mostly use the simpler application-level descriptions above and introduce protocol terminology only where it helps explain the problem.
The login flow
1. Make sure tokens are not coming back in the URL
Sign in to your application and look at the URL you return to after login.
If you see something such as:
access_token=...
or:
#access_token=...
The application is likely using an older OAuth flow where the token is returned directly through the browser.
For modern React applications, the preferred model is Authorization Code flow with PKCE.
Instead of returning an access token in the URL, the login flow returns a short-lived authorization code. The application then exchanges that code for tokens separately.
PKCE adds another protection to this process. Your application creates a random value before starting login, sends a derived version with the login request, and must present the original value when exchanging the authorization code.
In other words, stealing the authorization code alone should not be enough to complete the flow.
Most modern authentication SDKs handle this for you.
Verify: inspect the request that sends the browser to the login page. You should see:
response_type=code
code_challenge=...
code_challenge_method=S256
After login, the redirect URL should contain code=, not an access token.
2. Decoding a JWT is not the same as validating it
React applications often use libraries such as jwtDecode() to read information from a token.
There is nothing wrong with decoding a token when you want to display something such as the user's name.
The problem starts when decoded data is treated as trusted simply because it came from a JWT.
A JWT is just an encoded structure until its security properties have been checked.
For an OpenID Connect ID token, your authentication library should verify things such as:
- who issued the token
- whether it was issued for your application
- whether it is still valid
- whether the token has been changed
- any login-specific values such as a nonce when one was used
Most authentication SDKs already perform these checks.
The failure normally happens when application code separately decodes a token and then makes a security decision based on that decoded copy.
Verify: search your codebase for direct JWT decoding. For each use, check whether it is only reading information from a token that has already been validated or whether it is deciding whether the user should be trusted.
3. ID tokens and access tokens have different jobs
After login, you may receive both an ID token and an access token.
They may look similar, but they are not interchangeable.
The ID token is primarily about the authentication event and the signed-in user. Your React application may use information from it to understand who logged in.
The access token is the credential used to call an API.
One common mistake is sending the ID token to the API simply because it is already available in the authentication state.
That can appear to work if the API performs only limited validation, but it is not the intended use of the ID token.
Also, do not assume every access token is a JWT. Some identity providers issue opaque access tokens, where the client should not depend on the internal format at all.
Verify: inspect the Authorization header being sent to your API:
Authorization: Bearer <token>
Confirm that this is the access token intended for API access, not the ID token.
4. Avoid hardcoding every authentication endpoint
An authentication setup normally needs several endpoints, such as where to start login and where to exchange an authorization code for tokens.
You could configure each endpoint separately, but most modern identity providers publish metadata that authentication libraries can discover.
In practice, you normally configure the identity provider or issuer and let the SDK discover the relevant endpoints.
This becomes particularly useful when you have development, staging, and production environments or when an identity provider rotates signing keys.
Verify: search the codebase for:
/authorize
/token
.well-known
and for hardcoded identity-provider hostnames.
A provider or issuer URL in environment configuration is normal. The same authentication endpoint repeated in several components usually deserves another look.
5. Check your redirect URLs carefully
When authentication finishes, the identity provider sends the browser back to a URL registered for your application.
That URL needs to match what the application is actually sending.
Small differences matter:
https://example.com/callback
is not necessarily the same as:
https://example.com/callback/
The scheme, hostname, port, path, and trailing slash all matter.
Loose redirect matching would make it easier to send an authentication response somewhere you did not intend, which is why identity providers normally require exact registered URLs.
Production authentication traffic should also use HTTPS.
Verify: compare the redirect URL configured in your React application with the value registered at the identity provider.
Do not compare them visually and assume they are the same. Compare the full value.
Token storage and refresh
If your React application never receives OAuth tokens because you are using the backend model described earlier, much of this section becomes a backend concern.
If tokens do reach the browser, these decisions matter.
6. Know where your tokens are stored
A lot of React examples contain something similar to:
localStorage.setItem("token", token);
It is convenient because the token survives page reloads.
The downside is equally simple: JavaScript running in your application's origin can also read it.
There are several common approaches: localStorage is easy to use and survives browser restarts, but is accessible to JavaScript running on the page. sessionStorage is also accessible to JavaScript, but normally limited to the lifetime of a browser tab. In-memory storage keeps the token only in application memory — it disappears when the page reloads, which reduces persistence but also means your authentication library needs another way to restore the session. A Web Worker can keep some authentication state outside the normal main-thread JavaScript environment, which can make direct token extraction harder — though it is not a complete solution to script injection: if malicious JavaScript is already running as part of your application, it may still be able to request authenticated operations even when it cannot directly read the token.
There is no storage option that makes browser security irrelevant.
Verify: sign in and inspect:
Developer Tools → Application → Local Storage
Developer Tools → Application → Session Storage
Understand where your SDK keeps its authentication state rather than assuming the default is appropriate for your application.
7. Protect refresh tokens
Access tokens should normally be relatively short-lived.
A refresh token has a different purpose. It allows the application to obtain new access tokens without asking the user to sign in every few minutes.
Because it can remain useful for much longer, stealing a refresh token can be more serious than stealing a short-lived access token.
If a refresh token is issued to a browser application, current security guidance requires additional protection. The most common approach is refresh-token rotation.
With rotation, using a refresh token produces a new refresh token and invalidates the old one:
Refresh token A
↓
New access token
Refresh token B
The next refresh uses B and receives C.
If token A later appears again, the identity provider knows an already-used token has been presented and can react according to its security policy.
Another approach is sender-constrained refresh tokens, but rotation is the mechanism most React developers are likely to encounter.
Verify: trigger two refreshes and compare the refresh token returned each time.
If your identity provider uses rotation, the value should change. You can also test what happens if an older refresh token is used again.
8. Keep access-token lifetimes reasonably short
If somebody steals a bearer access token, its expiry time puts a natural limit on how long it remains useful.
For that reason, access tokens should normally live for a relatively short period compared with refresh tokens.
There is no universal value such as "15 minutes is correct for every application." The right value depends on the application, API, risk, and how refresh is implemented.
However, if your browser application receives access tokens that remain valid for many hours, I would want to understand why.
Verify: if your access token is a JWT, decode it and compare iat and exp.
If it is an opaque token, check the configured token lifetime at your identity provider.
9. Make sure only one refresh happens at a time
This is one of the more React-specific problems in the list.
Assume a user leaves the application open for some time and the access token expires.
They return to a dashboard and several components immediately make API calls:
Component A → API → 401
Component B → API → 401
Component C → API → 401
Component D → API → 401
If every API call independently decides to refresh the token, you may suddenly have four refresh requests using the same refresh token.
Now combine this with refresh-token rotation.
The first refresh succeeds and replaces the old refresh token.
The other refresh requests may now be using a token that has already been consumed. Depending on your identity provider's reuse-detection behavior, that can invalidate the session.
From the user's point of view, they were simply logged out for no obvious reason.
The common pattern is to allow only one refresh request at a time. Other API calls wait for the same refresh operation and retry after it completes.
Verify: expire an access token and trigger several API calls simultaneously.
You should see one token refresh, followed by retries of the waiting API requests.
Browser security
Moving tokens to a safer place reduces what malicious code can steal.
It does not mean malicious code running inside your page becomes harmless.
10. Treat script injection as part of authentication security
If JavaScript is running inside your application's origin, the browser treats it as part of your application.
If your React code can read a token, malicious code may be able to read it too.
If you move the token into an HttpOnly cookie, the script can no longer directly read the cookie, which is useful.
But the browser still sends that cookie with authenticated requests.
So malicious code may be able to do this:
Injected script
↓
POST /account/change-email
↓
Browser automatically sends session cookie
The attacker never sees the cookie.
The same principle applies to a BFF. Keeping OAuth tokens completely outside React reduces token theft, but malicious code running inside the page may still use the authenticated backend while the session is active.
This is why token storage and script injection need to be treated as related but separate problems.
Verify: search the application for places where untrusted content can become markup or executable code.
Pay particular attention to: dangerouslySetInnerHTML, eval, dynamic script loading, direct DOM manipulation, and HTML returned from APIs or third parties.
Also run dependency scanning such as npm audit, but do not treat a clean dependency report as proof that the application cannot have an XSS problem.
11. Configure a Content Security Policy
A Content Security Policy, usually called CSP, is a browser security mechanism that controls which scripts and other resources your page is allowed to execute.
The browser receives the policy in an HTTP response header.
For example, you can restrict scripts to ones that came from sources you explicitly trust.
A well-designed CSP does not replace secure coding, but it adds another layer. Some script-injection bugs that would otherwise become exploitable can be blocked by the browser.
Be careful with broad exceptions such as unsafe-inline and unsafe-eval.
Sometimes an application needs one of them for compatibility, but they should not be added simply because that makes CSP errors disappear.
Verify: inspect your page's HTTP response headers and find Content-Security-Policy.
Check the script-src configuration and test your normal application journeys while watching the browser console for CSP violations.
12. Review dangerouslySetInnerHTML
React normally escapes values inserted into JSX.
For example:
<div>{comment}</div>
does not simply treat whatever is inside comment as executable HTML.
dangerouslySetInnerHTML deliberately bypasses that protection.
There are legitimate cases where an application needs to render HTML, such as content from a CMS. In those cases, untrusted HTML needs to be sanitized before it reaches the browser.
A maintained sanitizer such as DOMPurify is commonly used for this.
Verify: search the project for dangerouslySetInnerHTML.
For every result, trace where the HTML came from.
Pay particular attention if it originates from user input, URL parameters, API responses containing user-generated content, CMS content, or third-party feeds.
13. If you use cookies, protect against cross-site requests
If your application uses the backend model described earlier, authentication may rely on a session cookie instead of exposing OAuth tokens to React.
That changes the browser security model.
The browser can automatically attach cookies to requests, which means another website may try to make the user's browser send an authenticated request to your application.
This class of attack is usually called Cross-Site Request Forgery, or CSRF.
Cookie attributes such as Secure, HttpOnly, and SameSite are important.
Secure ensures the cookie is only sent over HTTPS.
HttpOnly prevents normal JavaScript from directly reading it.
SameSite controls when the browser sends the cookie with cross-site requests.
For a BFF-style architecture, SameSite=Strict provides strong protection where your application architecture allows it.
But SameSite should not automatically be treated as your entire CSRF design. Applications spread across subdomains or different origins need more careful handling.
Depending on the architecture, additional protection can include anti-forgery tokens, strict CORS configuration, or requiring a custom request header that another origin cannot send without passing a CORS preflight.
Verify: inspect the Set-Cookie header used for your authenticated session.
Check the values of Secure, HttpOnly, SameSite, Domain, and Path.
Then confirm what actually prevents another website from submitting a state-changing request through the authenticated browser.
Authorization belongs in the API
React needs authorization information so it can show the right interface.
The final security decision still belongs on the server.
14. Hiding a button is not authorization
This is normal React code:
{isAdmin && <AdminPanel />}
If the user is not an administrator, there is no reason to show them an administrator button.
That is good UI behavior.
It is not access control.
The user controls their browser. They can modify frontend state, change JavaScript, or simply call your API without opening the React application.
For every sensitive action, the API needs to independently decide whether the authenticated user is allowed to perform it.
Verify: identify your role-based conditional rendering and route guards.
For each sensitive action behind them, find the corresponding authorization check in the API.
15. Do not trust object IDs coming from React
Assume your profile page calls:
GET /users/42
The user changes that to:
GET /users/43
What happens?
The fact that the caller asked for user 43 does not mean the caller is allowed to see user 43.
The API needs to take the authenticated identity and decide whether that identity can access the requested object.
This problem is not limited to user IDs. It also applies to /orders/1024, /tenants/acme, /documents/239, and /projects/81.
Any identifier coming from the frontend should be treated as a request to access something, not proof that access is allowed.
Verify: log in as one test user and change an object identifier to one belonging to another user.
Do the same for update and delete operations, not only reads.
16. Protected routes protect the UI
A React application may contain something such as:
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
This is useful.
It prevents unauthenticated users from navigating through parts of the interface they cannot use.
But the route guard executes in frontend code.
The API behind that page still needs to reject requests without valid authentication, and it also needs to reject authenticated users who do not have the required permission.
Think of the React route guard as user experience. Think of the API as the actual security boundary.
Verify: choose a protected page and identify the API calls it makes.
Call one API without authentication, and again with a valid user who should not have access.
Both requests should fail at the API.
Logout and sessions
Authentication design does not end when login succeeds.
Logout is where several assumptions normally become visible.
17. Know what logout actually does
A simple React logout implementation may look like this:
setUser(null);
localStorage.removeItem("token");
The UI is now logged out.
But there may still be several other things active: a login session at the identity provider, a refresh token, and access tokens that remain valid until expiry.
These are related but separate pieces of state.
Ending the identity-provider session does not automatically mean every access token previously issued becomes unusable immediately.
Similarly, clearing React state does not mean the identity-provider session has ended.
For a proper logout flow, decide what you actually want to terminate.
For some applications, removing the local application session is enough.
For others, logout should also terminate the login session at the identity provider and revoke refresh tokens.
Verify: write down what your logout operation actually does.
Capture an access token, sign out, and test whether that token is still accepted by your API.
Also sign out and then immediately start login again. If the identity provider signs the user in without any interaction, the provider session may still be active.
That may be intended, but it should be something you chose rather than discovered later.
18. Test logout with more than one tab
Users open multiple tabs.
If the user signs out from one tab, another tab may still show the application as authenticated.
Eventually its API calls may begin failing, but until that happens the UI can remain in a stale state.
React applications can synchronize logout state using mechanisms such as BroadcastChannel, storage events, or SDK-provided session events.
The exact implementation depends on how your authentication state is stored.
Verify: open the application in two tabs.
Sign out in the first tab and watch the second one.
It should respond to the logout instead of continuing indefinitely with stale authentication state.
19. Configure how long sessions can live
There are two different limits worth considering.
An idle timeout ends the session when the user has been inactive for a period of time.
An absolute timeout ends the session after a maximum amount of time, even if the user remains active.
They solve different problems.
An idle timeout helps with the unattended browser.
An absolute timeout prevents a session from continuing forever simply because activity keeps occurring.
There is no single timeout value that fits every React application. A consumer application and an application handling financial or administrative data may reasonably have very different requirements.
The important part is that these values are deliberate.
Verify: check the session configuration at your identity provider and application.
Also test what happens when a session expires while the user is actively using the application. The result should normally be a clean transition back to authentication rather than a page full of failed API requests.
Before you ship
There are two more things worth checking even though they are not part of the authentication protocol itself.
Development configuration
Authentication code tends to accumulate temporary debugging.
For example:
console.log(accessToken);
console.log(user);
That may be useful while developing and something you do not want in production.
Also check for development identity-provider URLs, test tenants, temporary client IDs, authentication bypasses, test users, and verbose SDK logging.
Do not assume your build process removes everything automatically.
Search for them before release.
Dependencies
Your authentication code runs in the same browser environment as the rest of the application.
A vulnerable or compromised dependency can therefore become part of the authentication threat model as well.
Run dependency scanning as part of CI rather than only when somebody happens to remember.
Tools such as npm audit are useful here.
Automated dependency updates are useful as well, but they still need testing, especially for authentication libraries where a small behavior change can affect login or session handling.
Also delete packages you no longer use.
Keep authentication policy outside React
Some authentication features should normally be controlled by your identity platform rather than implemented separately inside each React application.
As an example: multi-factor authentication, step-up authentication for sensitive actions, password policies, account recovery, brute-force protection, and authentication based on device or risk signals.
Your React application may need to start or respond to these flows.
It should not need to implement the security mechanism itself.
Assume you later decide that changing a bank account number requires an additional authentication step.
Ideally, you should be able to change that policy at the identity layer without building your own MFA mechanism inside the React application.
This also makes authentication reusable. If you have five applications, the authentication policy remains in one place rather than becoming five separate implementations.
Common problems to check
Users get logged out occasionally and nobody can reproduce it
Check whether several failed API requests can start token refresh at the same time.
This becomes particularly important when refresh-token rotation is enabled.
See rules 7 and 9.
We use an HttpOnly cookie, so XSS cannot affect authentication
An HttpOnly cookie prevents JavaScript from directly reading that cookie.
That is useful, but injected JavaScript may still be able to make authenticated requests because the browser attaches the cookie.
See rules 10 and 13.
Logout works because React clears the user
That proves the front-end state was cleared.
It does not tell you whether the identity-provider session, refresh token, or existing access tokens were affected.
See rule 17.
Everything works locally but authentication fails in production
Check the issuer and endpoint configuration, redirect URL, origin configuration, and environment-specific client settings.
See rules 4 and 5.
If you only check four things
Nineteen items can look like a lot if you already have an application running.
If I had to start with four, I would check these first.
1. Does the OAuth token need to be in React at all? If you already have a backend, consider whether the authentication state can stay there.
2. Can more than one refresh happen at the same time? This is easy to miss and can produce difficult-to-reproduce logout problems.
3. Is authorization enforced by the API? Anything that exists only in React is UI behavior, not the final security decision.
4. What really happens when the user signs out? Check the frontend session, identity-provider session, refresh token, and existing access tokens separately.
After that, work through the remaining items against your own application.
The important part is not whether an SDK documentation page says the feature is supported. It is whether you can verify what your application is actually doing.
What your authentication SDK should handle
A good React authentication SDK should remove a lot of protocol-level work from your application.
For example, the SDK should normally handle things such as starting the login flow, generating PKCE values, processing the authorization response, validating the authentication result, maintaining authentication state, obtaining access tokens, and performing logout against the identity provider where configured.
It should also give you a clear way to control how browser-side authentication state is stored.
But there is an important boundary here.
An authentication SDK cannot secure everything around authentication.
Your application still owns browser security, including CSP and safe rendering of untrusted content. Your APIs still own authorization. If you use cookie-based sessions or a BFF, your architecture still needs proper CSRF protection. Your application also needs to handle concurrent API calls, expired sessions, multi-tab behavior, and the user experience around authentication failures.
With WSO2 Identity Platform, the React and browser SDKs are intended to handle the OAuth and OpenID Connect mechanics so that application developers do not need to implement these flows directly. The exact behavior depends on the SDK version and configuration, so check the current SDK documentation when deciding things such as storage, token handling, and logout behavior.
The main point is to keep that boundary clear: let the identity platform and SDK handle authentication mechanics, while your React application and APIs remain responsible for the security decisions that belong to them.


