Fix guide · high · auth_token_in_localstorage

Auth token stored in localStorage or sessionStorage

What this rule means

Your code calls localStorage.setItem("<auth-shaped-key>", token) (or sessionStorage). Any XSS that lands on your page can read the token directly. Move auth state to in-memory + HttpOnly refresh cookies.

Why it matters

JavaScript-readable storage (localStorage, sessionStorage, IndexedDB, in-memory but globally exposed) is the broadest possible XSS impact zone. Once any cross-site script executes on your page — reflected, stored, DOM-based, polyfill.io CDN compromise, every-other-XSS-shape — that script can read whatever's in storage. There's no permissions boundary inside the document context.

For auth tokens specifically:

  1. Any XSS = full account takeover. The token is the credential.
  2. localStorage persists across tabs and reloads. A drive-by XSS on Tuesday is still useful to the attacker on Friday.
  3. Browser extensions can also read localStorage. "Allow access to all sites" extensions, which most users grant freely, can scrape every site's localStorage.
  4. No "secure storage" exists in browsers. The Web Crypto API can't keep a key out of script reach in a way that survives the page reload.

The auth working group (and Auth0, Okta, Microsoft, Google) all recommend the same pattern:

Common library patterns that store in localStorage by default (and how to flip the flag):

How to fix it

Supabase example (the most common case we see):

import { createClient } from "@supabase/supabase-js";

// Memory-only token cache. Auth state survives within a single tab session
// but doesn't persist to localStorage where XSS can read it.
const memoryStorage = {
  _data: new Map(),
  getItem: (k) => memoryStorage._data.get(k) ?? null,
  setItem: (k, v) => { memoryStorage._data.set(k, v); },
  removeItem: (k) => { memoryStorage._data.delete(k); },
};

export const supabase = createClient(URL, ANON_KEY, {
  auth: {
    storage: memoryStorage,
    persistSession: false,         // don't try to re-hydrate from disk
    autoRefreshToken: true,
    detectSessionInUrl: false,
  },
});

For your own auth (not Supabase):

// 1. Store access token in memory only.
let accessToken = null;
export const getToken = () => accessToken;
export const setToken = (t) => { accessToken = t; };

// 2. Refresh cookie is set by your backend on /api/auth/login:
//    Set-Cookie: refresh=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/api/auth
// 3. On page load (or 401), call /api/auth/refresh:
async function refresh() {
  const r = await fetch("/api/auth/refresh", { credentials: "include" });
  if (!r.ok) { redirectToLogin(); return null; }
  const { access_token } = await r.json();
  setToken(access_token);
  return access_token;
}

Pair with:

Did vibecheck flag this on your app?

If you reached this page from a vibecheck inspection report, the redacted match in your scan output is the exact string we found in your bundle. After applying the fix above, run the inspection again — the finding should clear.

Run another inspection