localStorage is readable by any JavaScript running on your origin, including injected scripts. If you store a session or JWT there, one cross-site scripting bug anywhere on the site is enough for an attacker to read the token and impersonate the user. The safer pattern is to keep the token in an httpOnly cookie that scripts cannot read, so an XSS bug cannot steal the session outright.
Why it's a problem
Tokens in localStorage have no protection against script access, and web apps pull in a lot of third-party script. A compromised dependency, a reflected XSS, or a malicious browser extension can read the token and send it anywhere. Because the token grants a full authenticated session, this turns a contained scripting bug into a complete account takeover.
The pattern
// after login
localStorage.setItem("token", res.token);
// on each request
fetch("/api/me", {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});The fix
// server sets the token in an httpOnly cookie the browser sends automatically
res.cookie("session", token, {
httpOnly: true, // not readable by JS
secure: true, // HTTPS only
sameSite: "lax", // limits cross-site sending
maxAge: 60 * 60 * 1000,
});
// client just makes the request; no token handling in JS
fetch("/api/me", { credentials: "include" });Why AI tools write this
localStorage is the path of least resistance in a single-page app. It is trivial to write, works the same in every framework, and appears in most quick-start auth tutorials. httpOnly cookies require the server to set them and a little CSRF care, so the assistant defaults to the version that gets a working login fastest, which is the one that leaves the token exposed.
The quick fix
- Have the server set the token in an httpOnly, secure, sameSite cookie instead of returning it to JS.
- Send requests with credentials: 'include' and stop attaching the token in JS.
- Add CSRF protection once you move to cookies (sameSite plus a token for state-changing requests).
- If you must keep a token in JS, keep it in memory only, never in localStorage or sessionStorage.