Skip to content

23 tools · 116 fixes · 32 issue guides · Sources: Supabase, GitHub, OWASP, Vercel + 32

Issues

Session tokens stored in localStorage

A JWT or session id in localStorage is readable by any script on the origin. One XSS becomes account takeover.

Share link · Share on X

By ShipSecurely Editorial · Updated · How we make these guides

criticalAccess controlAuth

Shows up in: Next.js, Supabase, Firebase, Vercel

What it is

Do not store authentication tokens, session IDs, JWTs, or refresh tokens in localStorage or sessionStorage. The OWASP Session Management Cheat Sheet states this as a warning: those APIs are accessible to any JavaScript executing in the origin, so one cross-site scripting bug discloses every token. The sheet's preferred replacements are an HttpOnly; Secure; SameSite=Strict cookie, or a backend-for-frontend that holds the tokens on the server. The warning points at the OAuth 2.0 for Browser-Based Apps guidance for that backend pattern.

MDN's session management guide reaches the same storage conclusion. Cookies are the recommended place for a session ID because the HttpOnly attribute stops script from reading the value. MDN also records the limit: script that already runs in the page can still send requests that include the cookie, so HttpOnly reduces theft of the token and does not remove XSS. See Cross-site scripting (XSS).

sessionStorage is not a safer token store. The Session Management warning names both APIs. Session storage is cleared when the tab closes, and any script on that origin can still call getItem while the tab is open. The OWASP HTML5 Security Cheat Sheet says not to store session identifiers in local storage, because the data is always accessible by JavaScript, and that cookies can mitigate this with the httpOnly flag. It also says a single cross-site scripting bug can steal all the data in these objects.

The Web Security Testing Guide's browser storage test, WSTG-CLNT-12, lists authentication tokens such as JWTs in localStorage or sessionStorage as a common concern, because they are accessible via JavaScript and may be exposed through XSS. The same test says to check whether tokens or session identifiers are still there after logout.

This is not the same bug as a secret API key in the frontend bundle. A Supabase secret key, a Stripe secret key, or a model key in client code lets anyone act as your server. A session token in localStorage lets whoever can run script as that user act as that user. Both are serious, and they are different objects. See Secret keys shipped in the frontend.

Supabase and Firebase client SDKs are a common reason a token is discussed in the browser. The publishable key is not the user's session. Supabase's API key guide says anyone can read the publishable key, so it only reaches what row level security allows, and that a secret key bypasses every row level security policy. Supabase's Next.js server-side guide puts the publishable key in NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY and says server-side rendering requires cookie-based session storage, handled by @supabase/ssr. Calling localStorage.setItem with the user's access token or refresh token is a separate choice. Follow the cookie guide instead of copying the access token into a storage key of your own. Those @supabase/ssr cookies are still readable by script, because the browser needs the refresh token. They are not the HttpOnly fix.

Firebase documents how the client SDK persists auth state. LOCAL keeps it after the browser window closes and is the default for web apps. SESSION keeps it for the current tab. NONE keeps it in memory until refresh. The Persistence interface describes LOCAL as long-term storage such as localStorage or IndexedDB, SESSION as sessionStorage, and COOKIE as cookie persistence for server-side rendering. browserLocalPersistence is the LOCAL implementation that uses localStorage. The auth reference documents browserCookiePersistence for hybrid rendering and middleware, and says that preview API should not be used in production. Do not also copy getIdToken() into localStorage for an API you wrote. getIdToken() returns the JWT that identifies the user to a Firebase service. The Auth interface says signOut does not automatically revoke the user's ID token, so a token you copied aside can keep working until it expires.

How to check in 5 minutes

  1. Search the repo for localStorage.setItem and sessionStorage.setItem near token, jwt, access_token, refresh, and session. A hit that stores an auth token is the bug.

  2. On a signed-in staging app you own, open DevTools, then Application, then Local Storage and Session Storage. A JWT is three base64 segments separated by dots. A long opaque session id under a key such as token or session counts too.

  3. In the console, read it back with localStorage.getItem and the key you found. If the console can read it, script on the origin can read it, and XSS can too.

  4. Sign out. Confirm the storage key is gone and that the server no longer accepts that session. Clearing a React state variable while the token remains, or while the server still honors it, is not logout. WSTG-CLNT-12 calls out tokens that persist after logout. On Supabase, getClaims() checks the signature and the expiry. The advanced SSR guide says an unexpired token still passes that check after the session is revoked, so call getUser() when logout has to be rejected.

How to fix

Next.js

Keep the session on the server. Set an HttpOnly, Secure, SameSite=Strict cookie from your auth route or from a backend-for-frontend. OWASP prefers Strict for session cookies. Lax permits top-level cross-site navigations that use safe HTTP methods. Use Lax only when a top-level navigation has to send the cookie, and treat SameSite as one CSRF control, the way MDN describes it, not as the whole defense.

Read the session inside Server Components, Route Handlers, and Server Actions through your auth library's server helper. Do not pass the raw access token into a client component so the component can store it.

If you move from a bearer header to a cookie, the browser attaches that cookie on its own. That is a different CSRF shape than a header the script had to set. Next.js compares the Origin header to the Host header (or X-Forwarded-Host) on Server Actions and aborts the request when they do not match. That comparison is for Server Actions. A cookie-authenticated Route Handler that changes data needs its own Origin or CSRF check. MDN and the OWASP session sheet both treat SameSite as one CSRF control, not the whole defense.

Supabase

Use the cookie helpers in Supabase's Next.js server-side auth guide when the server needs to see the session. @supabase/ssr stores the access token and the refresh token in cookies. Those cookies are not HttpOnly. The advanced SSR guide says both tokens are meant to be read by the browser, because the browser needs the refresh token to keep the session. Script on the origin can still read them. A cookie that script cannot read is a server-only session, the backend-for-frontend in the OWASP session sheet. Create the server client from the request cookies inside the Route Handler or Server Action. Do not persist the access token with localStorage.setItem, and do not describe the SSR cookies as the HttpOnly fix.

The publishable key may live in the browser. The user's access token and refresh token ride in the cookies that package writes, and those cookies stay readable by script. The Next.js server guide says not to trust getSession() in server code, including Proxy, because it reads the cookie without revalidating it. getClaims() verifies the token signature and expiry. On projects with asymmetric signing keys, the default for new projects, it verifies locally against a cached copy of the project's public keys. On projects still using a symmetric secret, it calls the Auth server instead. The advanced guide says that check does not notice a revoked session: an unexpired token stays valid to getClaims(). Call getUser() where rejecting the session after logout matters.

Firebase

Do not copy the ID token into your own storage. browserLocalPersistence stores the SDK's auth state in localStorage, and LOCAL is the web default. If your own API needs to know who the caller is, verify the token on the server for that request. Do not write localStorage.setItem('token', idToken) on top of the SDK.

The Auth interface says signOut does not automatically revoke the user's ID token. A copy you placed in localStorage can still be sent to your API after the SDK user is cleared. Delete that key on logout, and treat a token that already left the browser as usable by whoever has it until it expires.

browserCookiePersistence is documented for hybrid rendering and middleware. Firebase marks it as a public preview and says not to use that API in a production environment. It is not the fix to ship while that warning stands.

Vercel

The host does not change where the browser stores a token. Deployment protection on a preview URL is a Vercel login in front of that URL. It is not your app's session cookie. Production still needs the session you actually chose: an HttpOnly cookie from a server-only session, or the provider's documented cookies. Supabase's SSR cookies are not HttpOnly.

Fix XSS sinks in the same pass. HttpOnly stops script from reading that server-only cookie. It does not stop script from calling your API as the signed-in user while the page is open, and it does not apply to a Supabase SSR cookie the browser can read. Cross-site scripting (XSS) is the other half.

Copy the fix prompt

Paste this into Cursor, Lovable, Bolt, or Claude Code. Read the diff before you merge it.

You are moving session tokens out of browser storage. Do not add features.

Search for localStorage.setItem, sessionStorage.setItem, localStorage.getItem, and sessionStorage.getItem. Also search for keys named token, jwt, access_token, refresh_token, and session.

1. If a value is an authentication token, session id, JWT, or refresh token, stop writing it to localStorage or sessionStorage.
2. A real HttpOnly cookie is a server-only session, such as a backend-for-frontend that holds the tokens. Do not treat @supabase/ssr as that fix. Its cookies are readable by script because the browser client needs the refresh token. Use that package instead of a storage key you invented. For Firebase, do not copy getIdToken() into your own storage key, and do not switch on browserCookiePersistence while the reference still says that preview is not for production.
3. Next.js compares Origin to Host only on Server Actions. A cookie-authenticated Route Handler that changes data needs its own Origin or CSRF check. SameSite is not that check.
4. On logout, clear any leftover storage key and invalidate the server session. Do not only clear React state. On Supabase, getClaims() still accepts an unexpired token after the session is revoked. Call getUser() when that logout has to be rejected.
5. Do not print token values. Do not add a new login method.

List each storage key you removed and what now holds the session.

Questions

Is sessionStorage safe because the tab closes?

No. The Session Management Cheat Sheet warning names both localStorage and sessionStorage. The tab can stay open, and any script on the origin can call sessionStorage.getItem while it does. Closing the tab limits how long the value sits there. It does not stop XSS from reading it.

If XSS is fixed, can the token stay in localStorage?

The Session Management Cheat Sheet still says not to store those tokens there. A later template, a markdown renderer, or a third-party script can add a sink. HttpOnly keeps the token out of script when a sink appears. Fix the sinks too. MDN notes that script running in the page can still send requests as the user, so HttpOnly is not a reason to ignore XSS.

Is the Supabase publishable key this bug?

No. The publishable key is meant for the browser, and row level security is what limits it. A user access token or refresh token in localStorage is the session. Whoever reads it can call your API as that user until the token stops working. The two mistakes are written up separately in Secret keys shipped in the frontend.

Sources

Related

The weekly fix

The weekly fix: 3 real security mistakes in vibe-coded apps and how to fix them, every Tuesday.

One email, every Tuesday. A welcome note follows the first signup.

The security issues hitting Supabase, Vercel, Next.js, and Stripe apps

One short email a week, with the fix.

One email, every Tuesday. A welcome note follows the first signup.