HttpOnly Cookies vs localStorage for JWT Storage

“Where do I put the JWT?” is the question every SPA authentication design eventually collides with, and the two candidates — localStorage and an HttpOnly cookie — fail to different attacks. Put the JWT in localStorage and any cross-site scripting flaw reads it; put it in an HttpOnly cookie and the browser attaches it to forged cross-site requests. This guide is a decision aid: it lays out the XSS/CSRF trade-off matrix explicitly, then implements the recommended HttpOnly Secure SameSite cookie approach with a paired anti-CSRF token in TypeScript/Express, and adds refresh rotation. It is part of Token Storage Patterns for Web and Mobile within Secure Authentication & Session Architecture. Because the cookie choice makes cross-site scripting the deciding factor, read this alongside XSS mitigation.

Prerequisites

  • A TypeScript/Express API issuing JWTs to a browser SPA on a known origin
  • HTTPS in all environments so Secure cookies are honoured
  • cookie-parser and a CSRF library (csrf-csrf or a hand-rolled double-submit)
  • An existing XSS baseline: output encoding plus a Content Security Policy

Expected Outcomes

  • A reasoned choice backed by an explicit threat-model matrix, not folklore
  • JWT delivered in an HttpOnly Secure SameSite cookie, invisible to script
  • Cookie-authenticated state-changing routes protected by a CSRF token
  • Refresh tokens rotated with reuse detection, so theft is self-limiting

Step 1: Enumerate the Threat Model of Each Store

Do not choose on convenience — choose on which attack each store exposes you to and whether that attack has a reliable mitigation. The matrix below is the whole decision in one view.

Threat / property localStorage HttpOnly cookie
Readable by injected JavaScript Yes — full token theft No — script cannot read it
Auto-sent on cross-site requests No Yes — CSRF exposure
Mitigation exists for the main risk No reliable stop for XSS reads Yes — SameSite + CSRF token
Token portability once stolen High — usable off-device Low — bound to browser/cookie
Survives page reload Yes Yes
Sent automatically to your API No — manual Authorization header Yes — by the browser
Works cross-origin without CORS creds Yes Needs SameSite=None + CORS credentials

The asymmetry is the point. localStorage’s dominant risk — XSS reading the token — has no dependable mitigation; you cannot prevent JavaScript you already lost control of from reading a value JavaScript is allowed to read. The HttpOnly cookie’s dominant risk — CSRF — has mature, well-tested mitigations. You should prefer the store whose worst case you can actually defend. That is the HttpOnly cookie.

JWT Storage Decision: XSS vs CSRF Two columns. localStorage leads to XSS token theft with no reliable mitigation. HttpOnly cookie leads to CSRF risk, which SameSite plus an anti-CSRF token reliably mitigates, so the cookie path ends in a hardened state. JWT in localStorage readable by any script Risk: XSS reads token no reliable mitigation Token stolen off-device durable compromise JWT in HttpOnly cookie not readable by script Risk: CSRF SameSite + CSRF token Hardened default theft risk minimised

Issue the JWT as a cookie the browser cannot expose to JavaScript. Use the __Host- prefix to pin it to the exact host, and set the full flag triad.

import express from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());
app.use(cookieParser());

app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  const accessJwt = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' }
  );

  // JWT lives in an HttpOnly cookie: no localStorage, no document.cookie access.
  res.cookie('__Host-jwt', accessJwt, {
    httpOnly: true,      // invisible to JavaScript → XSS cannot read it
    secure: true,        // HTTPS only
    sameSite: 'lax',     // blocks most cross-site sends; pair with a CSRF token
    path: '/',
    maxAge: 1000 * 60 * 15,
  });
  res.json({ ok: true });
});

// Auth middleware reads the JWT from the cookie, never from a header the SPA sets.
function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
  const token = req.cookies['__Host-jwt'];
  if (!token) return res.status(401).json({ error: 'no_token' });
  try {
    (req as any).user = jwt.verify(token, process.env.JWT_SECRET!);
    next();
  } catch {
    res.status(401).json({ error: 'invalid_token' });
  }
}

An HttpOnly cookie is auto-attached by the browser, so any state-changing route needs an anti-CSRF token the attacker’s page cannot read or forge. Use the double-submit pattern: a non-HttpOnly CSRF cookie that JavaScript echoes back in a header, verified against a signed value.

import { doubleCsrf } from 'csrf-csrf';

const { generateToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET!,
  cookieName: '__Host-csrf',
  cookieOptions: { httpOnly: false, secure: true, sameSite: 'lax', path: '/' },
  getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,
});

// The SPA fetches a CSRF token after login and echoes it on every mutation.
app.get('/csrf-token', requireAuth, (req, res) => {
  res.json({ csrfToken: generateToken(req, res) });
});

// State-changing routes require BOTH the auth cookie and a valid CSRF token.
app.post('/account/email', requireAuth, doubleCsrfProtection, (req, res) => {
  // Reaches here only if x-csrf-token matches the signed __Host-csrf cookie.
  res.json({ ok: true });
});

The auth JWT stays HttpOnly (unreadable by script); the CSRF token is deliberately readable by your own script so it can be placed in the x-csrf-token header — a value a cross-site attacker cannot obtain because they cannot read your origin’s cookies. For a deeper treatment of the synchronizer vs double-submit choice, see CSRF defense.


Step 4: Add Refresh Token Rotation with Reuse Detection

Keep the access JWT short-lived (15 minutes) and issue a rotating refresh token — also in an HttpOnly cookie — that self-destructs on reuse.

app.post('/refresh', async (req, res) => {
  const presented = req.cookies['__Host-refresh'];
  const record = presented ? await refreshStore.findByToken(presented) : null;
  if (!record) return res.status(401).json({ error: 'unknown_refresh' });

  if (record.consumedAt) {
    // A used refresh token replayed = theft signal → revoke the whole family.
    await refreshStore.revokeFamily(record.familyId);
    res.clearCookie('__Host-refresh', { path: '/' });
    return res.status(401).json({ error: 'reuse_detected' });
  }

  await refreshStore.markConsumed(record.id);
  const next = await refreshStore.issue({ familyId: record.familyId, userId: record.userId });
  const accessJwt = jwt.sign({ sub: record.userId }, process.env.JWT_SECRET!, { expiresIn: '15m' });

  res.cookie('__Host-jwt', accessJwt, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 900000 });
  res.cookie('__Host-refresh', next.token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 604800000 });
  res.json({ ok: true });
});

Verification

Confirm the JWT is unreadable by script, that cookie flags are correct, and that CSRF protection actually blocks a forged request.

# 1. Log in and inspect the cookie flags — must show HttpOnly and Secure
curl -s -i -c jar.txt -X POST https://localhost:3000/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse"}' \
  | grep -i 'set-cookie'
# Expect: __Host-jwt=...; HttpOnly; Secure; SameSite=Lax; Path=/

# 2. A state-changing request WITHOUT a CSRF token must be rejected
curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt \
  -X POST https://localhost:3000/account/email -d '{"email":"[email protected]"}'
# Expect: 403

# 3. Confirm no token is written to web storage anywhere in the bundle
grep -rn "localStorage.setItem\|sessionStorage.setItem" dist/ | grep -i token
# Expect: no matches

Expect the Set-Cookie header to carry HttpOnly; Secure, the token-less mutation to return 403, and the storage grep to find nothing. A browser DevTools check should show the JWT under the Cookies tab, not under Local Storage.


Troubleshooting

Symptom Likely cause Fix
Cookie not stored by the browser Secure set but the app served over plain HTTP, or __Host- used with a Domain attribute Serve over HTTPS and drop Domain; __Host- requires Secure and Path=/ with no domain
Every mutating request returns 403 SPA not echoing the CSRF token in x-csrf-token, or CSRF cookie marked HttpOnly Fetch /csrf-token, send it as the header; keep the CSRF cookie non-HttpOnly
Cross-site SPA cannot send the cookie at all SameSite=Lax/Strict blocks the cross-origin call Use SameSite=None; Secure plus CORS credentials: 'include', and keep the CSRF token
Refresh endpoint logs out legitimate users Two tabs racing the same refresh token trip reuse detection Serialise refresh per session, or grace-window the immediately-prior token within the family
JWT still visible in Local Storage in DevTools Legacy client code persisting the token after login Remove the localStorage.setItem call; rely solely on the cookie

Common Implementation Mistakes


Frequently Asked Questions

Which is safer for JWTs, HttpOnly cookies or localStorage?

HttpOnly cookies are the safer default. A token in localStorage is readable by any JavaScript on the page, so one cross-site scripting flaw exfiltrates it for use off-device — a durable, portable compromise. A token in an HttpOnly cookie cannot be read by script, which downgrades token theft to at most on-page action while the tab remains open. The catch is that cookies are auto-attached to cross-site requests, so you must add SameSite and an anti-CSRF token; with those in place, the cookie’s worst case is smaller and, crucially, defensible.

If cookies introduce CSRF risk, why prefer them over localStorage?

Because the two risks are not equally solvable. CSRF has mature, well-understood mitigations — SameSite attributes plus a synchronizer or double-submit token — that reliably stop the attack. There is no comparable, dependable way to prevent cross-site scripting from reading a value in localStorage once an injection has occurred. Choosing the cookie means trading an unsolvable exposure for a solvable one, which strictly reduces your attack surface even though it introduces a new risk on paper.

Do I still need a CSRF token if I set SameSite=Strict?

Yes, in almost every real application. SameSite=Strict breaks legitimate top-level navigation — following a link into your app from an email or another site arrives without the cookie — so most apps settle on Lax, which still permits some cross-site requests. Cross-site SPAs need SameSite=None, which removes cookie-based protection entirely. An explicit CSRF token does not depend on these attribute edge cases, so it remains the defense of record regardless of which SameSite value your flows force you into.