All posts

Field guide

Where to store a JWT: localStorage vs cookies in AI-built apps

Ask an assistant to build login and it will almost always keep your auth token in localStorage. It works, it is easy, and it means one cross-site scripting bug anywhere on your site hands an attacker a full session. Here is why the httpOnly cookie is the safer default, the honest trade-off, and the minimal switch.

Last updated

When you ask an AI coding tool to add authentication to a single-page app, the token almost always ends up in localStorage. It is the path of least resistance: two lines, works the same in every framework, and it appears in most quick-start tutorials. The trouble is that localStorage is readable by any JavaScript running on your origin, and that is a larger set of code than you think.

Why localStorage is the risky default

The token is the session. Anything that can read it can act as the user without ever knowing their password. And a modern web app runs a lot of script it did not write: analytics, widgets, a dependency three levels deep in the tree, a browser extension the user installed. If any of that is compromised, or if a single cross-site scripting bug lets an attacker inject script, the token in localStorage is right there to be read and sent anywhere. There is no browser control that stops a script from reading storage on its own origin, because that is what the API is for.

// the common AI-generated pattern
localStorage.setItem("token", res.token);

// and on every request
fetch("/api/me", {
  headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});

This is not a subtle bug in the code. The code is correct. The problem is the storage choice, which is why a functional test never catches it and why it survives to production in so many AI-built apps.

Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.

See a live scan →

Why the httpOnly cookie is safer

A cookie marked httpOnly cannot be read by JavaScript at all. The browser stores it, attaches it to requests automatically, and does not expose it to any script, including an injected one. So the same XSS bug that would have handed over a localStorage token now hits a wall: the attacker can still act within the page, but they cannot lift the session and use it elsewhere. You have not fixed XSS, you have removed its biggest prize.

// server sets the token where scripts cannot reach it
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 stops handling the token entirely
fetch("/api/me", { credentials: "include" });

Notice the flags do real work here. Without secure the cookie can leak over plain HTTP, and without sameSite it can be sent on a forged cross-site request. All three belong on any session cookie; the fix for a cookie missing these flags covers the details.

The honest trade-off: CSRF

Moving to cookies introduces one thing you did not have to think about with localStorage: cross-site request forgery. Because the browser sends the cookie automatically, another site can cause a logged-in user's browser to fire a request at your app. This is a real risk, but unlike the localStorage exposure, it has a clean and well-understood fix. Set sameSite so the cookie is not sent cross-site, and require an unguessable CSRF token on state-changing requests. See what CSRF is and the fix for missing CSRF protection.

That is the whole calculus. localStorage carries an exposure with no switch to disable it. Cookies carry an exposure that a known pair of controls closes. You are trading an unsolvable problem for a solvable one, which is why the cookie is the better default even though it is slightly more work.

If you want the strongest version

Keep the short-lived access token in memory, a variable in your app rather than any persistent store, and use an httpOnly refresh cookie to get a new one after a reload. Nothing sensitive sits on disk for a later script to read, and the refresh token never leaves the browser's cookie jar. It is more moving parts than most projects need on day one, but it is the pattern to grow into. What you want to avoid, in every version, is the token living in localStorage across page loads.

Frequently asked questions

Isn't localStorage fine if my app has no XSS?

The problem is that you cannot be sure it has none. Web apps pull in a lot of third-party script, and a single reflected input, a compromised dependency, or a malicious browser extension is enough to run code on your page. localStorage gives that code your token with no further work. An httpOnly cookie does not remove the XSS bug, but it removes the payoff of stealing the session outright, which is the difference between a contained bug and an account takeover.

But cookies are vulnerable to CSRF, so isn't it a wash?

It is a trade of one risk for another, but the cookie risk has a clean, well-understood fix and the localStorage risk does not. Set sameSite on the cookie and require a CSRF token on state-changing requests, and CSRF is closed. There is no equivalent switch that makes a token in localStorage unreadable by script. You are trading an unsolvable exposure for a solvable one.

What about storing the token in memory instead?

In-memory storage, a variable in your app rather than localStorage, is genuinely safe from the persistent-read problem, because there is nothing on disk for a script to grab later. The downside is that the token is lost on refresh, so you need a refresh flow, often backed by an httpOnly cookie anyway. Memory-only for the access token plus an httpOnly refresh cookie is a strong pattern. What you want to avoid is the token sitting in localStorage across page loads.

My AI put the token in localStorage. What is the minimal change?

Have the server set the token in an httpOnly, secure, sameSite cookie at login instead of returning it in the response body, then send requests with credentials included and stop attaching the token in JavaScript. Add a CSRF token once you are on cookies. It is a small change on both the server and the client, and it removes the class of attack entirely rather than mitigating it.

Check what your app is doing now

If an assistant built your auth, there is a good chance the token is in localStorage and the session cookie, if there is one, is missing its flags. Both are on the list of things we find most in AI-built apps. A scan reads the repo and points at the exact lines, so you can see where your token lives before someone else does. Run a free scan and check, or read the fix for a JWT stored in localStorage.

Prbl scans the AI-generated parts of your codebase for exactly the kinds of issues above.

Where to Store a JWT: localStorage vs Cookies in AI-Built Apps: Prbl