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
Securecookies are honoured cookie-parserand a CSRF library (csrf-csrfor 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
HttpOnlySecureSameSitecookie, 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.
Step 2: Implement the HttpOnly Secure SameSite Cookie
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' });
}
}
Step 3: Add a CSRF Token for Cookie-Authenticated Routes
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.
Related
- Token Storage Patterns for Web and Mobile — the parent guide covering every storage option and the BFF pattern
- Secure Authentication & Session Architecture — index for the authentication and session control set
- Cross-Site Scripting (XSS) Mitigation — the injection class that makes the cookie the safer JWT store
- Cross-Site Request Forgery (CSRF) Defense — the token patterns that cover the cookie’s request-forgery risk