---
title: "Session tokens stored in localStorage"
slug: session-token-in-localstorage
severity: critical
category: Access control
summary: "A JWT or session id in localStorage is readable by any script on the origin. One XSS becomes account takeover."
platforms:
  - Next.js
  - Supabase
  - Firebase
  - Vercel
updated: "2026-10-18"
published: "2026-10-18"
related:
  - xss
  - secret-keys-in-the-frontend
  - auth-checked-only-in-the-ui
  - weak-password-reset
---

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)](/issues/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](/issues/secret-keys-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)](/issues/xss) is the other half.

## Agent prompt

```text
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.
```

## FAQ

### 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](/issues/secret-keys-in-the-frontend).

## Sources

- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [OWASP HTML5 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html)
- [MDN session management](https://developer.mozilla.org/en-US/docs/Web/Security/Authentication/Session_management)
- [OWASP WSTG: Testing browser storage (WSTG-CLNT-12)](https://wstg.owasp.org/latest/4-Web_Application_Security_Testing/11-Client-side/12-Browser_Storage/)
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
- [Supabase server-side auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs)
- [Supabase advanced SSR guide](https://supabase.com/docs/guides/auth/server-side/advanced-guide)
- [Supabase API keys](https://supabase.com/docs/guides/getting-started/api-keys)
- [Firebase auth state persistence](https://firebase.google.com/docs/auth/web/auth-state-persistence)
- [Firebase Persistence interface](https://firebase.google.com/docs/reference/js/auth.persistence)
- [Firebase JS auth reference](https://firebase.google.com/docs/reference/js/auth)
- [Firebase Auth interface](https://firebase.google.com/docs/reference/js/auth.auth)
- [Next.js data security](https://nextjs.org/docs/app/guides/data-security)
