TL;DR: A JWT in localStorage is a login pass that any script on your page can read. Supabase puts it there by default. If one XSS bug slips in, that bug reads the token and becomes your user. The fix is to move the token into an HttpOnly cookie your JavaScript cannot touch.
Did you build your app with Lovable, Bolt, Cursor, or Replit? Did you use Supabase for login? Then your session token almost certainly sits in localStorage right now. Nobody chose that. It is how supabase-js ships.
The session holds two tokens. One is the JWT access token. The other is the refresh token. Any script on the page can read both. That includes a script a bad actor slips in.
Check your own app first with the free scan. No code access. No install. About 60 seconds. Then read on to see why the token's home matters so much.
What a JWT Actually Is
A JWT is a JSON Web Token. It is a signed string. It says one thing: this user is logged in, and here is who they are. The server issues it once. The browser sends it back on every request.
Think of it as an ID card. It is signed, so nobody can forge it. But it is not encrypted. Anyone who holds it can read it. And anyone who holds it can use it.
So the format was never the problem. The problem is where you keep the card. Leave it on a table anyone can reach, and the signature stops mattering.
Supabase hands you two cards at login. The access token is the JWT. It is short-lived, often 5 to 60 minutes. The refresh token quietly gets you a new access token when the old one runs out. Both need a safe home.
Why localStorage Is the Default, and the Problem
Run the Supabase sign-in call in a typical build. The library writes the session to localStorage. Both tokens go there. This is the default in supabase-js. Most AI-built apps get it with no extra code.
localStorage is easy. It survives a reload. Every tutorial uses it. That ease is the risk.
localStorage has no access rules. Every script on your domain can read it. Your own code can. An analytics tag can. An ad script can. A bad script can too. There is no check on who asks. One line does it: localStorage.getItem(...).
So your session is only as safe as your weakest script tag. A vibe-coded app pulls in widgets, embeds, and AI-suggested packages. That is a lot of surface.
Cookie is accessible to JavaScript via document.cookie. Any XSS vulnerability can steal this session token. That is the exact text our scanner returns. It fires when a login token is left open to scripts, at 0.98 confidence. A token in localStorage is worse still. It is readable by design.
localStorage vs Cookie vs Memory
A session token can live in three places in the browser. Each has a different risk. Here is the honest version.
- localStorage. Readable by any script. Survives a reload. High XSS exposure. This is the worst home for a login token.
- HttpOnly cookie. JavaScript cannot read it. Survives a reload. Low exposure, even during an XSS attack. This is the best home for the refresh token.
- In memory. A plain variable inside your running app. Lost on reload. Readable only while the app runs. Good for the short access token.
Notice the pattern. Anything your JavaScript can read, an attacker's JavaScript can read too. The one type that blocks a bad script outright is the HttpOnly cookie.
So the pattern that works splits the two tokens. Keep the access token in memory. Put the refresh token in an HttpOnly cookie. If a script gets injected, it might catch a token about to expire. It cannot get the one that renews it.
How One XSS Bug Becomes Account Takeover
Here is the attack, step by step.
- An attacker gets their script to run on your page. A comment box with no cleaning. A search field that echoes input. A third-party script that got hacked.
- The script runs
localStorage.getItem(...)and grabs the token. - It sends the token to a server the attacker controls.
- They replay the token in their own requests. Your server sees a valid signature. It treats them as the user.
No password. No two-factor prompt. They are your user, as far as the backend can tell, until the token expires.
So the token in localStorage and an XSS bug are not two problems. Together they are one incident. And XSS is common in AI-built code. Carnegie Mellon's SusVibes benchmark tested AI-written code across 200 real tasks and 77 flaw types. The code was correct 61% of the time. It was secure only 10.5% of the time. The app runs. It rarely holds up.
Our scanner flags this pattern for real, not in theory. It is an active finding, tied to exploit paths we see in vibe-coded apps.
The rule of thumb: assume a script you did not write will run on your page one day. Store your token where that script cannot read it.
The Safe Fix: HttpOnly Cookies with @supabase/ssr
Supabase ships a package for exactly this problem: @supabase/ssr. It moves session handling out of client JavaScript and into cookies your server sets and reads.
The cookie needs three flags. All three matter. Secure sends it over HTTPS only. HttpOnly hides it from JavaScript. SameSite stops other sites from sending it for the user.
// Before: the session goes to localStorage
import { createClient } from '@supabase/supabase-js';
// After: the session goes to HttpOnly cookies
import { createServerClient } from '@supabase/ssr';
// The cookie your server sets:
Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax
After you migrate, your server reads the session from the cookie on each request. The browser never hands the token to a script that asks. That one change closes the gap.
The login flow was always there. The HttpOnly cookie, the SameSite flag, the token rotation — those are the missing 20%. AI builders skip them. They aim for "it logs in," not "it is launch-ready."
Add Layers: Short Tokens and a Real CSP
Cookies fix storage. They do not fix a token that leaks anyway. You still want layers.
Keep access tokens short. Fifteen minutes, not seven days. A stolen short token is a small problem.
Keep the refresh token in an HttpOnly cookie. It mints new access tokens, so it needs the most protection. It should never touch a JavaScript variable.
Keep the access token in memory. A plain variable in your running app. It is gone on reload. That is fine. The HttpOnly refresh cookie gets you a new one.
Add a content security policy. A CSP tells the browser which scripts may run. It will not fix your own bug. But it stops most injected scripts cold. That cuts off the usual way attackers get code onto your page.
Content-Security-Policy: default-src 'self'; script-src 'self'
None of this is exotic. Real session systems have worked this way for years. AI builders just do not wire it in.
See what your app exposes right now
Free scan. Live URL. No code access. Results in about 60 seconds.
Run the free scanA Pre-Launch JWT Checklist
- Session lives in an HttpOnly, Secure, SameSite cookie. Not localStorage. Not sessionStorage.
- You moved to
@supabase/ssrif you use Supabase auth. - Access tokens expire in 15 minutes or less.
- Refresh tokens are HttpOnly only. Never exposed to client-side JavaScript.
- A CSP header is set and tested.
- Supabase RLS is on for every table, owner-scoped, never
USING (true). Check it with our Supabase RLS checker in about 60 seconds. - Any field that renders back to the page is cleaned against XSS.
- You ran a scan against the live URL, not just a code review.
Built on Lovable? Our Lovable security audit covers that builder's default gaps. On Bolt? The Bolt security audit does the same.
Why This Needs More Than a Code Review
A token in localStorage does not look broken. Your login works. Your dashboard loads. That is the trap.
AI-written code is often correct but rarely secure. It ships the login form, the session, the redirect. It does not ship the threat model. Nobody told the model to plan for an attacker running a script on your page. So it did not.
This is the same gap we keep finding. Missing rate limits. Disabled Supabase row level security. Missing security headers. Tokens sitting in localStorage. It is a batch of skipped choices, not one bad line.
A live scan catches it before a user does. Across the first 100 vibe-coded apps we scanned, the average Launch Readiness Score was 42/100. Tokens and secrets left readable in the browser were among the most common reasons. Our scan checks your live URL for exactly that. No code access. You get a score out of 100 and the gap to fix. Read the wider picture in our vibe coding security guide and the Supabase RLS guide.
Do you have EU users? Then this is a compliance question too. GDPR says you must guard personal data with the right technical steps. A token any script can steal fails that test. Add an AI feature and the EU AI Act piles on more rules. Our compliance check tests for both and hands back a score with fixes.
The Fix Takes an Afternoon
A JWT in localStorage is the default, not a call you made. Supabase writes it there unless you change it. So any script on your page can read it and log in as your user.
The fix is not hard. Move the session into HttpOnly, Secure, SameSite cookies with @supabase/ssr. Keep access tokens short. Keep refresh tokens out of JavaScript. Add a content security policy. No rewrite. An afternoon and a checklist.
The hard part is knowing the gap is there. Most founders do not, because the app works and the demo looks great. See our pricing for a full audit if you want a CTO-level fix list. It beats guessing at what else got skipped. The same header mistake shows up next door in CORS in vibe-coded apps.
Frequently Asked Questions
Is storing a JWT in localStorage really that dangerous?
Yes. Any script on your page can read localStorage. That includes a script an attacker injects through an XSS bug. Such a script can read the full session and act as your user with no password.
Does Supabase store the JWT in localStorage by default?
Yes. The standard supabase-js client writes both tokens to localStorage. To change that, set a different storage adapter, or move to the @supabase/ssr package.
Where should I store a JWT instead?
Keep the short access token in memory, in a plain variable. Keep the refresh token in an HttpOnly, Secure, SameSite cookie. The browser sends the cookie on its own, and no script can read it.
How do I fix this in an app I already launched?
Move your Supabase session handling to the @supabase/ssr package. It stores the session in HttpOnly cookies instead of localStorage. Pair it with short access tokens and a CSP. Then the fix still holds if another bug shows up.
Can a content security policy alone protect a JWT in localStorage?
No. A CSP lowers the chance of an attacker script running. But it does not stop your own scripts from reading localStorage. The safe fix is to take the token out of storage that scripts can read.
How do I check my app for this?
Run a free scan at launchreadycode.com. It checks your live app for tokens readable by JavaScript and for missing cookie flags. No code access. Results in about 60 seconds.