HomeGuidesJWT in localStorage or httpOnly cookie? Where to store it

Guide

JWT in localStorage or httpOnly cookie? Where to store it

localStorage or httpOnly cookie for your JWT? What XSS and CSRF can do with each, what Supabase, Clerk, Auth.js and Firebase do, and a tested Next.js fix.

18 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You ask your AI coding tool to add login to your app. It wires up a form, calls your API, and writes this:

const { token } = await res.json();
localStorage.setItem('token', token);

It works. Then you read a forum thread that says localStorage is insecure and you should use httpOnly cookies, followed by a reply that says cookies bring CSRF and localStorage is fine. Both sides are partly right. This guide explains what an attacker can actually do in each case, what Supabase, Clerk, Auth.js, and Firebase do by default (checked in their source code, not just their docs), the mistakes AI tools make, and a Next.js App Router pattern we ran locally that keeps the token out of browser JavaScript.

Checked on October 20, 2026 against @supabase/ssr 0.12.7, @supabase/supabase-js 2.118.0, @auth/core 0.41.3 (used by next-auth 5.0.0-beta.32), firebase 13.0.0, Clerk's "How Clerk works" docs, and Next.js 16.4.2. VibeLocked is independent and not affiliated with any of these companies.

The short version

  • If you use a hosted auth provider, keep its default storage. Supabase, Clerk, Auth.js, and Firebase each made a deliberate choice. Moving tokens around by hand usually breaks refresh and makes things worse.
  • If your own backend hands out a JWT, put it in a cookie your server sets with HttpOnly, Secure, SameSite=Lax, Path=/, and the __Host- prefix. Let server code attach it to API calls. Do not return it to browser JavaScript at all.
  • HttpOnly stops theft, not abuse. Injected script cannot copy an httpOnly cookie, but it can still make requests as the user while the tab is open.
  • Cookies need CSRF protection. SameSite=Lax does most of the work. Add an Origin check on routes that change data.
  • Never put tokens in URLs, never set httpOnly: false to "make it work", and never have client code read the auth cookie.
  • XSS is the real enemy in every option. A Content-Security-Policy and safe rendering matter more than where the token sits.

What XSS can do with each option

Cross-site scripting means an attacker gets their JavaScript to run on your page, for example through a comment rendered with dangerouslySetInnerHTML or AI output shown as raw HTML. That script runs with the same rights as your own code. What it can then do depends on where your token lives.

Token in localStorage (or sessionStorage, or a cookie your JavaScript set). The script reads it and sends it to the attacker's server. MDN notes that localStorage data has no expiration time, and any script on the origin can read all of it. The attacker now uses the token from their own machine, for as long as it stays valid, and if a refresh token sits next to it, they can keep minting new access tokens long after the victim closes the tab. OWASP's Session Management Cheat Sheet is now blunt about it: "Do not store authentication tokens, session IDs, JWTs, refresh tokens, or any credential in localStorage or sessionStorage," because "a single XSS vulnerability discloses every token." The HTML5 Security Cheat Sheet says the same about session identifiers.

Token in an httpOnly cookie. The script cannot read it. document.cookie simply does not include it, as MDN's Set-Cookie reference explains. But MDN adds the part people skip: an httpOnly cookie "will still be sent with JavaScript-initiated requests," such as fetch(). So the script can call /api/me, /api/export, or /api/delete-account, and your server sees a perfectly valid signed-in request.

The new IETF best practice for browser apps, RFC 10017: OAuth 2.0 for Browser-Based Applications (published August 2026), names this attack "proxying requests via the user's browser" and says it "also occurs with server-side web applications using HttpOnly session cookies" and "cannot be stopped or prevented by application-level security measures." What httpOnly buys you is containment. The attack only works while the victim's tab is open, it cannot be replayed from somewhere else next week, and you can end it by fixing the bug and revoking sessions. OWASP's session cheat sheet puts it the same way: HttpOnly "only protects the confidentiality of the cookie."

So the honest answer to "localStorage or httpOnly cookie?" is: the cookie is better because it turns token theft into a narrower, temporary attack. It is not a substitute for preventing XSS. Our XSS in React and Next.js guide and Content-Security-Policy guide cover that part.

What we saw in a real browser

We built the pattern from later in this guide with Next.js 16.4.2 and drove it with headless Chromium 156. After signing in, document.cookie returned an empty string, the cookie jar showed __Host-session with HttpOnly, Secure, and SameSite=Lax, and a fetch('/api/me') from the same page still returned the user's id and email. That is exactly the split described above: nothing to steal, but plenty to abuse if an attacker gets script onto the page.

The CSRF side of the trade

Cookies come with a cost localStorage does not have. The browser attaches them automatically, so another site could try to make the victim's browser send a request to yours. That is cross-site request forgery. A token kept in JavaScript and sent in an Authorization header is not exposed to CSRF, because the attacker's page has no way to add it.

Modern cookie attributes shrink this risk a lot:

  • SameSite=Lax tells the browser not to send the cookie on cross-site POST, fetch, or iframe requests, but still to send it when someone follows a normal link to your site. The OWASP CSRF Prevention Cheat Sheet calls Lax "a reasonable balance between security and usability." Strict never sends it cross-site, so users arriving from an email link look signed out on the first page.
  • Set SameSite yourself. MDN notes that only "some browsers use Lax as the default value," and when Lax is applied as a default, a looser version also allows POST requests within two minutes of the cookie being set.
  • Secure keeps the cookie off plain http.
  • The __Host- prefix makes the browser reject the cookie unless it has Secure, Path=/, and no Domain. OWASP notes such a cookie "cannot be (over)written from another subdomain." This matters because SameSite treats every subdomain of your site as "same site." The rules are in the RFC 6265bis cookie draft and MDN's cookie guide.

We checked Lax in the same Chromium run. A form on another site that auto-submitted a POST to our app arrived with no cookie. Clicking a plain link from that site to our app arrived with the cookie, as Lax intends. That is why you should never change data in a GET handler.

For defense in depth, check the Origin header on every route that changes data. The Next.js data security guide says Server Actions "compare the Origin header to the Host header" and abort on a mismatch. Route Handlers do not do this for you, so the pattern below adds it. If your API is called cross-origin with cookies, CORS is part of this story too; see CORS misconfiguration in vibe-coded apps.

What the big auth providers actually do

Before changing anything, find out what your provider already does. We read the current source of each library rather than relying on blog posts.

Provider Where the browser keeps the session HttpOnly? Notes
Supabase, supabase-js in the browser localStorage, key sb-<project-ref>-auth-token No (not a cookie) Default when no SSR package is used
Supabase, @supabase/ssr Cookie sb-<project-ref>-auth-token, may be split into .0, .1 chunks No by default Default options: Path=/, SameSite=Lax, httpOnly: false, max age 400 days
Clerk __session cookie on your domain, plus __client on Clerk's domain __session: No. __client: Yes __session is a JWT that expires after 60 seconds
Auth.js / NextAuth v5 authjs.session-token cookie (__Secure- on https) Yes Encrypted JWT by default, database session id with an adapter, 30 day max age
Firebase Auth (web) IndexedDB database firebaseLocalStorageDb No (not a cookie) Falls back to localStorage, then sessionStorage; ID tokens last one hour

Supabase. In the browser, the auth client in GoTrueClient.ts uses globalThis.localStorage when it is available, and supabase-js sets the key to sb-<project-ref>-auth-token, as the client reference describes for persistSession. With @supabase/ssr, the browser client writes cookies through document.cookie (cookies.ts), and the default options set httpOnly: false. This is on purpose. The SSR advanced guide answers "How do I make the cookies HttpOnly?" with "This is not necessary," because "the browser-based side of your application needs access to the refresh token." The sessions docs say httpOnly storage is only possible for server-only apps that render plain HTML. Do not flip the option to true unless your app never uses the Supabase client in the browser.

Clerk. The How Clerk works page lists the long-lived __client cookie as HttpOnly with SameSite Lax, set on Clerk's Frontend API domain. The __session cookie on your own domain is not HttpOnly, because Clerk's JavaScript sets it. Clerk's answer is the lifetime: the session token expires in 60 seconds and the SDK refreshes it about every 50 seconds, so a stolen copy is almost useless by the time an attacker tries it. Read the token on the server with Clerk's helpers instead of parsing the cookie yourself.

Auth.js (NextAuth). In cookie.ts the session token cookie is httpOnly: true, sameSite: "lax", path: "/", with a __Secure- prefix on https, and the CSRF token cookie gets the stricter __Host- prefix. Without a database adapter the session strategy is an encrypted JWT, as the session strategies page explains, which is why your AUTH_SECRET must stay secret; see Auth.js AUTH_SECRET.

Firebase. The web SDK's getAuth() tries indexedDBLocalPersistence first, then browserLocalPersistence, then browserSessionPersistence. The persistence docs confirm the default keeps users signed in after the browser closes. IndexedDB is just as readable by injected script as localStorage. The session management docs say ID tokens "last for an hour," while refresh tokens only expire when the user is deleted, disabled, or makes a major account change. If you need httpOnly, Firebase offers session cookies created with the Admin SDK, lasting 5 minutes to 2 weeks, with persistence set to NONE in the browser.

Refresh tokens and rotation

Most setups pair a short-lived access token (Supabase usually 5 minutes to 1 hour, Firebase 1 hour, Clerk 60 seconds) with a long-lived refresh token. The refresh token is the real prize, because it can mint new access tokens.

Rotation means each refresh token works once and returns a new one. RFC 9700, the OAuth security best practice, explains the benefit: if an attacker and the real user both use the same refresh token, the server notices the reuse and revokes the session. Supabase does this: the sessions docs say refresh tokens "can only be used once," with a 10 second reuse window, and a reuse outside it ends the whole session. Ending a session deletes its refresh token, but the access token already issued keeps working until it expires; Supabase signOut scopes and session revocation shows what each sign out scope revokes.

Rotation is not a cure for XSS. RFC 10017 describes "persistent token theft," where injected script steals the newest tokens every few seconds, and notes that rotation and short lifetimes are then "not sufficient," because the attacker can make sure the app stops using the latest refresh token. The lesson is the same as before: tokens that JavaScript cannot read are safer than tokens it can, and preventing script injection is what really protects you. RFC 10017 strongly recommends a backend-for-frontend design, where tokens stay on the server, for "applications that handle personal data." The pattern below is a small version of that.

Typical AI-generated mistakes

These show up again and again in code from Cursor, Lovable, Bolt, v0, and Claude Code.

1. localStorage.setItem('token', token) for a custom backend. Fine for a demo, but now every XSS bug hands out working tokens, often with a refresh token beside them. If you wrote the backend, have it set a cookie instead.

2. Turning off httpOnly to "fix" a bug. The client cannot find the token, so the tool suggests httpOnly: false or setting the cookie with document.cookie. That throws away the one thing the cookie was for. RFC 10017 marks storing tokens in cookies from JavaScript as "NOT RECOMMENDED." The real fix is to stop needing the token in the browser: call a server route that attaches it.

3. Reading or decoding the auth cookie in client code. Code like jwtDecode(Cookies.get('token')).role === 'admin' to show an admin page. The user can edit that cookie, and a role check in the browser is a hint, not security. See client-side auth is not security.

4. Trusting a cookie on the server without verifying it. A cookie is text the browser sends. Verify the signature, or ask your auth provider, on every request. For Supabase this is the getSession vs getUser vs getClaims problem.

5. Tokens in URLs. /dashboard?token=... or fetch('/api/data?access_token=...'). RFC 6750, the bearer token standard, says "Don't pass bearer tokens in page URLs." URLs land in history, logs, and analytics, and can leak through the Referer header.

6. SameSite=None and wide CORS together. To make a separate frontend talk to an API, tools set SameSite=None and reflect any Origin with credentials. That combination invites both CSRF and cross-site data theft.

7. Returning the token in the JSON body anyway. The server sets a perfect httpOnly cookie, then also returns { token }, and the client saves it to localStorage. Check what your login route responds with.

8. Logout that does not actually log out. We hit this one while testing. In Next.js 16.4.2, (await cookies()).delete('__Host-session') sent Set-Cookie: __Host-session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT with no Secure. Chromium ignored it because a __Host- cookie must be Secure, so the user stayed signed in. Passing the full options to delete(), or setting the cookie again with maxAge: 0 and the same attributes as the code below does, fixed it.

A tested Next.js App Router pattern

This is for apps where your own backend (Express, FastAPI, Rails, a separate Node API) returns a JWT at login. If you use Supabase, Clerk, Auth.js, or Firebase, follow their Next.js guides instead and keep their storage.

The idea: the browser posts the email and password to a Next.js Route Handler. The handler calls your backend, puts the token in an httpOnly cookie, and returns only { ok: true }. Server Components and Route Handlers read the cookie and add Authorization: Bearer when they call your backend. Browser JavaScript never sees the token. The Next.js authentication guide recommends the same cookie options, and cookies() can only set cookies in Route Handlers and Server Functions.

The session helpers:

// lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';

const isProd = process.env.NODE_ENV === 'production';

// __Host- means: Secure, Path=/, no Domain, so no subdomain can set or
// overwrite it. Browsers reject a __Host- cookie without Secure, so plain
// http dev servers get a normal name.
export const SESSION_COOKIE = isProd ? '__Host-session' : 'session';

const cookieOptions = {
  httpOnly: true, // page JavaScript cannot read it
  secure: isProd, // https only
  sameSite: 'lax' as const, // not sent on cross-site POST, fetch, or iframes
  path: '/',
};

export async function setSessionCookie(token: string, maxAgeSeconds: number) {
  const store = await cookies();
  store.set(SESSION_COOKIE, token, { ...cookieOptions, maxAge: maxAgeSeconds });
}

export async function getSessionToken() {
  const store = await cookies();
  return store.get(SESSION_COOKIE)?.value ?? null;
}

export async function clearSessionCookie() {
  const store = await cookies();
  // Same attributes as when it was set, or the browser keeps the old cookie.
  store.set(SESSION_COOKIE, '', { ...cookieOptions, maxAge: 0 });
}

An Origin check for Route Handlers that change data:

// lib/same-origin.ts
import type { NextRequest } from 'next/server';

// Defense in depth against CSRF for Route Handlers that change state.
// Browsers send Origin on every cross-origin request and on same-origin
// POST, PUT, PATCH and DELETE. Server Actions do this check for you;
// Route Handlers do not.
export function isSameOrigin(req: NextRequest) {
  const origin = req.headers.get('origin');
  if (!origin) return false;
  const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host');
  try {
    return new URL(origin).host === host;
  } catch {
    return false;
  }
}

The login Route Handler:

// app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { setSessionCookie } from '@/lib/session';
import { isSameOrigin } from '@/lib/same-origin';

export async function POST(req: NextRequest) {
  if (!isSameOrigin(req)) {
    return NextResponse.json({ error: 'Bad origin' }, { status: 403 });
  }

  const body = await req.json().catch(() => null);
  const email = typeof body?.email === 'string' ? body.email : '';
  const password = typeof body?.password === 'string' ? body.password : '';
  if (!email || !password) {
    return NextResponse.json({ error: 'Email and password required' }, { status: 400 });
  }

  // Your backend checks the password and returns a token.
  const res = await fetch(`${process.env.API_URL}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    cache: 'no-store',
  });
  if (!res.ok) {
    return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
  }

  const { access_token, expires_in } = await res.json();
  await setSessionCookie(access_token, Number(expires_in) || 900);

  // Do not send the token back in the body: the browser never needs it.
  return NextResponse.json({ ok: true });
}

Calling your backend from server code, plus a page and a logout route:

// lib/api.ts
import 'server-only';
import { getSessionToken } from './session';

// Calls your backend with the token from the httpOnly cookie.
// Runs only on the server, so the token never reaches browser JavaScript.
export async function apiFetch(path: string, init: RequestInit = {}) {
  const token = await getSessionToken();
  if (!token) return null;
  const headers = new Headers(init.headers);
  headers.set('Authorization', `Bearer ${token}`);
  return fetch(`${process.env.API_URL}${path}`, { ...init, headers, cache: 'no-store' });
}
// app/account/page.tsx
import { redirect } from 'next/navigation';
import { apiFetch } from '@/lib/api';

export default async function AccountPage() {
  const res = await apiFetch('/me');
  if (!res || !res.ok) redirect('/login');
  const user = await res.json();
  return <h1>Signed in as {user.email}</h1>;
}
// app/api/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { clearSessionCookie } from '@/lib/session';
import { isSameOrigin } from '@/lib/same-origin';

export async function POST(req: NextRequest) {
  if (!isSameOrigin(req)) {
    return NextResponse.json({ error: 'Bad origin' }, { status: 403 });
  }
  await clearSessionCookie();
  return NextResponse.json({ ok: true });
}

On the client, the login form just calls fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }). Same-origin requests send the cookie automatically, so no token handling and no credentials: 'include' are needed. API_URL has no NEXT_PUBLIC_ prefix, so it stays on the server; see keep secrets out of frontend code.

If your backend also returns a refresh token, store it in a second httpOnly cookie the same way and refresh from server code when the backend answers 401. Ask your backend for rotation and reuse detection.

What we verified locally

We ran this exact code with Node 22.23.3, next build and next start (production mode), against a small local stand-in for the backend. Type-checked with TypeScript 7.0.2 in strict mode.

  • Correct login: 200, body {"ok":true} with no token, and the header set-cookie: __Host-session=...; Path=/; Expires=...; Max-Age=900; Secure; HttpOnly; SameSite=lax.
  • Wrong password: 401, no cookie. Origin https://evil.example: 403. No Origin header: 403.
  • /api/me and /account with the cookie: the user's data. Without it: 401 and a redirect to /login.
  • Logout: set-cookie: __Host-session=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=lax, and in Chromium the cookie was gone and /api/me returned 401.
  • In Chromium 156: document.cookie empty after login, cross-site form POST without the cookie, cross-site link with it, and the plain cookies().delete() logout bug described above.

Checklist

  • Using Supabase, Clerk, Auth.js, or Firebase: default storage kept, no hand-copied tokens in localStorage
  • Custom backend: token in an httpOnly, Secure, SameSite=Lax cookie with the __Host- prefix, set by the server
  • Login responses do not include the token in the JSON body
  • No httpOnly: false and no auth cookie written with document.cookie
  • No client code reads or decodes the auth cookie to make security decisions
  • Server verifies the token on every request
  • No tokens in URLs, query strings, logs, or analytics
  • Origin check on Route Handlers that change data, and no state changes on GET
  • Logout clears the cookie with the same attributes it was set with
  • Short-lived access tokens, rotating refresh tokens
  • XSS prevention and a strict Content-Security-Policy in place, because every option above depends on them

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Lovable, Bolt, or v0. Share file names and code only, never keys, tokens, or .env contents.

Audit how this project stores and sends auth tokens. Find every localStorage, sessionStorage, IndexedDB, document.cookie, js-cookie and cookies().set call, every place a token or JWT is returned in a response body, put in a URL or query string, logged, or decoded in client code, and every cookie option object. Tell me which auth provider we use (Supabase, Clerk, Auth.js, Firebase, or a custom backend) and whether we keep its default storage. For a custom backend, move the token into an httpOnly, Secure, SameSite=Lax cookie named with the __Host- prefix, set only by a server Route Handler, never returned in JSON, and attached as an Authorization header only from server code. Flag httpOnly: false, SameSite=None, tokens in URLs, client-side role checks based on the token, and cookies read on the server without signature verification. Add an Origin header check to every Route Handler that changes data, and make logout clear the cookie with the same attributes it was set with. Do not change how Supabase, Clerk, Auth.js or Firebase store their own sessions.

Review the diff before you accept it. The security review prompt guide has more prompts like this, and Next.js Server Actions security covers the other half of protecting data changes.

Sources

Questions

Should I store my JWT in localStorage or an httpOnly cookie?

If you control the backend, put it in an httpOnly, Secure, SameSite=Lax cookie that your server sets, and let your server attach the token to API calls. OWASP's Session Management Cheat Sheet now says not to store authentication tokens, JWTs, or refresh tokens in localStorage or sessionStorage, because a single XSS bug discloses every token. If you use Supabase, Clerk, Auth.js, or Firebase, keep their default storage and spend the effort on preventing XSS instead.

Does an httpOnly cookie protect me from XSS?

Only partly. HttpOnly stops page JavaScript from reading the cookie, so injected script cannot copy the token and use it from the attacker's own machine later. It does not stop the script from acting as the user while the page is open: the browser still attaches the cookie to the script's fetch calls. In our test, document.cookie was empty and the same page still loaded the signed-in user's data. Preventing XSS is still the fix.

Do httpOnly cookies make my app vulnerable to CSRF?

Cookies are sent automatically, so cookie-based auth needs CSRF protection and header-based auth does not. SameSite=Lax blocks the classic attack: in our test a cross-site form POST arrived without the cookie. Add an Origin header check on routes that change data, never change data on GET, and use the __Host- prefix so a sibling subdomain cannot plant or overwrite your cookie. Next.js Server Actions already check Origin; Route Handlers do not.

Is it safe that Supabase stores the session in localStorage or a non-httpOnly cookie?

It is a deliberate design choice, not a bug. Supabase's docs say making the cookies httpOnly is not necessary because the browser needs the refresh token to keep the session alive. The trade is that any XSS on your site can read the access and refresh tokens. Keep that design, but treat XSS as session theft: no unsanitized HTML, a strict Content-Security-Policy, and reauthentication before password or email changes.

Can I put the token in the URL to pass it between pages or to my API?

No. RFC 6750, the bearer token standard, says not to pass bearer tokens in page URLs. URLs end up in browser history, server and proxy logs, analytics, and can leak to other sites through the Referer header. Send tokens in the Authorization header from server code, or let the browser send a cookie. If a provider puts a token in the URL during sign in, let its SDK read it and clear it right away.