Fix guide · high · auth_token_in_localstorage
Auth token stored in localStorage or sessionStorage
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:
- Any XSS = full account takeover. The token is the credential.
- localStorage persists across tabs and reloads. A drive-by XSS on Tuesday is still useful to the attacker on Friday.
- Browser extensions can also read localStorage. "Allow access to all sites" extensions, which most users grant freely, can scrape every site's localStorage.
- 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:
- Access token in JavaScript memory only — a module-scoped variable, never written to storage. Page reload requires re-auth (cheap if you have a refresh cookie).
- Refresh token in an HttpOnly cookie scoped to the auth domain. JavaScript cannot read it. Browser attaches automatically to requests to your /api/auth/refresh endpoint.
- Short access-token lifetime — 5-15 minutes. Even if the in-memory token leaks via some other path, it's stale fast.
Common library patterns that store in localStorage by default (and how to flip the flag):
- Auth0 SPA SDK —
cacheLocation: "memory"(default islocalStorage) - Supabase JS client —
auth: { storage: { /* custom memory store */ } }— the default is localStorage; this is the most common vibecheck-flagged source - Firebase JS — by default tokens live in IndexedDB (similar exposure); use
indexedDBLocalPersistenceonly on highly-trusted contexts, or migrate to session-cookie via Firebase Auth REST - NextAuth.js / Auth.js — uses cookies by default; flag fires if you've overridden to localStorage
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:
- Strict CSP — the best XSS mitigation
- Subresource Integrity — covers the supply-chain XSS class
- Trusted Types opt-in — defense-in-depth
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