Where to Store a JWT: HttpOnly Cookies vs localStorage Compared
Backend DevelopmentLesson 3 of 47 min read
What you'll learn
- Explain what each storage option exposes you to: XSS, CSRF or neither
- Set cookie attributes correctly:
HttpOnly,Secure,SameSite,Pathand the__Host-prefix - Protect a cookie-authenticated route against cross-site requests
- Choose a sensible storage design for a single-page app and defend the trade-off
On this page
A security reviewer leaves one comment on a pull request for a newsletter reader app: "Access token in localStorage. Please explain." The developer's honest reaction is that every tutorial does it. The reviewer is not being fussy; the comment points at the real question of this lesson. Wherever a token lives in the browser, something can reach it, and the choice is about which attack you are choosing to be exposed to, because no option removes both.
What each location exposes
Two attacks matter. XSS (cross-site scripting) means an attacker has managed to run their JavaScript inside your page, usually through unescaped user content or a compromised third-party script. CSRF (cross-site request forgery) means another website causes the victim's browser to send a request to yours, riding on credentials the browser attaches automatically.
| Where the token lives | Readable by page JavaScript? | Sent automatically by the browser? | Main exposure |
|---|---|---|---|
localStorage / sessionStorage | Yes | No | XSS can read and exfiltrate the token and use it from anywhere, for as long as it is valid |
Cookie without HttpOnly | Yes | Yes | Both XSS theft and CSRF |
Cookie with HttpOnly | No | Yes | CSRF (needs defences); XSS can still make requests as the user while the page is open, but cannot take the token away |
| JavaScript variable in memory | Yes (to your code) | No | XSS can read it while the page lives; lost on refresh, so you need a way to get a new one |
Read the third row carefully, because it is where most arguments go wrong. An HttpOnly cookie does not make XSS harmless: injected script can still call your API from the victim's own session. What it prevents is *token theft*: the attacker cannot copy the credential and use it later from their own machine. That narrows the damage window to the time the victim has the page open. The real fix for XSS is not to have it: output encoding, a Content Security Policy and careful use of third-party scripts (see the OWASP Top 10).
Cookie attributes that do the work
| Attribute | Effect | Recommendation |
|---|---|---|
HttpOnly | Hides the cookie from document.cookie | Always for auth cookies |
Secure | Sent only over HTTPS (browsers treat localhost as an exception for development) | Always in production |
SameSite=Lax | Not sent on cross-site subrequests (images, fetch, form POST); sent on top-level navigations such as clicking a link | A good default |
SameSite=Strict | Never sent on cross-site requests, including link clicks | For sensitive apps where a missing cookie on first arrival is acceptable |
SameSite=None | Sent on all cross-site requests (requires Secure) | Only for deliberate cross-site embedding |
Path, Max-Age | Scope and lifetime | Match the token lifetime; scope refresh cookies to the refresh path |
__Host- name prefix | Browser enforces Secure, Path=/ and no Domain | Cheap hardening against cookie tossing from sibling subdomains |
A cookie-based service you can run
This server issues the access token in a cookie, never in the response body, so page JavaScript never touches it. State-changing routes also check the Origin header against an allow-list, because SameSite is a strong layer but not the only one you should rely on (browser versions, same-site subdomains and any route that accepts GET for changes all weaken it).
import { createServer } from "node:http";
import { sign, verify } from "./jwt-lib.mjs";
const SECRET = process.env.JWT_SECRET;
if (!SECRET || SECRET.length < 32) throw new Error("set JWT_SECRET to at least 32 characters");
const ALLOWED_ORIGINS = new Set(["https://reader.example"]);
const COOKIE = "__Host-access";
const TTL = 900;
const send = (res, status, body, headers = {}) => {
res.writeHead(status, { "content-type": "application/json", ...headers });
res.end(JSON.stringify(body));
};
function readCookie(req, name) {
for (const pair of (req.headers.cookie ?? "").split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key === name) return rest.join("=");
}
return null;
}
function currentUser(req) {
const token = readCookie(req, COOKIE);
if (!token) return null;
try { return verify(token, SECRET); } catch { return null; }
}
createServer((req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
if (req.method === "POST" && pathname === "/login") {
const iat = Math.floor(Date.now() / 1000);
const token = sign({ sub: "usr_77", role: "member", iat, exp: iat + TTL }, SECRET);
// The token travels only in the cookie; the body never contains it.
return send(res, 200, { ok: true }, {
"set-cookie": `${COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${TTL}`,
});
}
if (req.method === "POST" && pathname === "/logout") {
return send(res, 200, { ok: true }, {
"set-cookie": `${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
});
}
const user = currentUser(req);
if (!user) return send(res, 401, { error: "not signed in" });
if (req.method === "GET" && pathname === "/me") return send(res, 200, { user: user.sub });
if (req.method === "POST" && pathname === "/subscription/cancel") {
// Cookies are sent automatically, so state-changing routes also check where the request came from.
const origin = req.headers.origin;
if (origin && !ALLOWED_ORIGINS.has(origin)) return send(res, 403, { error: "origin not allowed" });
return send(res, 200, { cancelled: true });
}
send(res, 404, { error: "not found" });
}).listen(4200, () => console.log("cookie demo listening"));Now drive it with curl. -c stores cookies that the server sets and -b sends them back, which is the part a browser does for you. In the output I replaced the real token with <jwt>, since it changes every run.
# -c saves received cookies to a file, -b sends them back
curl -s -i -c jar.txt -X POST http://localhost:4200/login -w "\n"
curl -s -b jar.txt http://localhost:4200/me -w "\n"
curl -s -b jar.txt -X POST http://localhost:4200/subscription/cancel \
-H "Origin: https://shop.attacker.example" -w "\n-> %{http_code}\n"
curl -s -b jar.txt -X POST http://localhost:4200/subscription/cancel \
-H "Origin: https://reader.example" -w "\n-> %{http_code}\n"
# no cookie jar: not signed in
curl -s http://localhost:4200/me -w "\n-> %{http_code}\n"Output
HTTP/1.1 200 OK
content-type: application/json
set-cookie: __Host-access=<jwt>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
{"ok":true}
{"user":"usr_77"}
{"error":"origin not allowed"}
-> 403
{"cancelled":true}
-> 200
{"error":"not signed in"}
-> 401The login response sets the cookie with every attribute from the table, and its body is only {"ok":true}. The cancel request that claims to come from an unknown origin is refused with 403 while the same request from the allowed origin succeeds, and a request without the cookie is simply not signed in. Curl cannot demonstrate the browser-enforced parts (HttpOnly hiding the cookie from scripts, or SameSite withholding it on cross-site requests), so check those in the browser's developer tools, under the Application and Network tabs.
The browser side of a cookie design is short. Because the cookie is attached automatically, the client only has to ask the browser to include credentials. This snippet is not run here, as it needs a page and a server at a real origin.
// The token is never visible to this code; the browser attaches the cookie.
const res = await fetch("https://api.reader.example/me", {
credentials: "include", // send cookies on cross-origin requests too
});
if (res.status === 401) {
// not signed in, or the access cookie expired: try the refresh route once
}If the API lives on a different origin than the page, the server must also answer CORS requests with Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin value. A wildcard * is refused by browsers whenever credentials are included.
A pragmatic design for a single-page app
- Keep the access token in memory (a variable), or in an
HttpOnlycookie if your API and page share a site. - Keep the refresh token in an
HttpOnly,Secure,SameSitecookie whosePathis limited to the refresh endpoint, so it is not sent with every API call. - On page load, call the refresh endpoint to obtain a fresh access token; on 401, refresh once and retry.
- Add a CSRF defence to the refresh route and to every state-changing route:
SameSite, anOrigincheck and, where appropriate, a CSRF token. - Treat XSS prevention (CSP, escaping, dependency hygiene) as the primary control, not as a footnote.
Common mistakes and the symptoms they cause
- A long-lived refresh token in `localStorage`. Symptom: one XSS bug or a malicious browser extension yields a credential that works for weeks from anywhere.
- Auth cookie without `Secure`. Symptom: on public Wi-Fi the cookie is sent over plain HTTP and can be read by someone on the network.
- `SameSite=None` out of habit. Symptom: cross-site POSTs carry the cookie and a forged form on another site can change state. Use
Laxunless you really embed cross-site. - State changes on `GET` routes. Symptom:
SameSite=Laxstill sends the cookie on top-level navigations, so a plain link or image-style prefetch can trigger the action. UsePOST,PUTorDELETE. - Token in a query string (
/me?token=...). Symptom: tokens show up in server logs, analytics, browser history and theRefererheader of outgoing links. - CORS wildcard with credentials. Symptom: the browser console shows
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include', and developers "fix" it by reflecting any origin, which is worse.
How this shows up in real projects and interviews
This is one of the most common security discussions in front-end and full-stack interviews, and the weak answer is "cookies are more secure" or "localStorage is fine". The strong answer names XSS and CSRF, explains what HttpOnly prevents and does not, describes the in-memory access token with a cookie-held refresh token, and acknowledges that XSS must be prevented either way. On the job, the practical work is configuring cookie attributes, CORS and an Origin check, and verifying them in the browser. Next, the lifecycle side: JWT Security: Expiry, Revocation and the Mistakes to Avoid.
Try it yourself
Extend cookie-server.mjs with POST /auth/refresh that reads a second cookie named __Host-refresh, issues a new access cookie and sets the refresh cookie with Path=/auth/refresh. Test with curl that the refresh cookie is sent to /auth/refresh but not to /me. Then make the origin check apply to /auth/refresh as well, and test it with a bad Origin header.
Show a hint
Note that the __Host- prefix requires Path=/, so a path-scoped refresh cookie must use a different name such as refresh without the prefix. Use curl -c and -b with the jar file and inspect jar.txt to see which path each cookie was stored under.
Key takeaways
- Every storage option is exposed to something: web storage to token theft via XSS, cookies to CSRF, in-memory tokens to XSS during the page's lifetime.
HttpOnlystops script from copying a token but does not stop injected script from making requests as the user.- Set
HttpOnly,SecureandSameSite=Lax(or Strict) on auth cookies, prefer the__Host-prefix, and scope refresh cookies to the refresh path. - Protect cookie-authenticated routes against CSRF with
SameSite, an Origin check and no state changes on GET. - A common SPA design is an in-memory access token plus an HttpOnly refresh cookie, with XSS prevention treated as essential.
Test yourself
A trick from this lesson's ride: Cookies ride along by themselves; web storage only moves when your own code carries it. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
Is it safe to store JWT in localStorage?
It works, but it exposes the token to any script running on your page, so a single XSS bug lets an attacker copy it and use it elsewhere until it expires. It avoids CSRF because nothing is sent automatically. If you use it, keep access tokens short-lived, avoid putting long-lived refresh tokens there, and invest in XSS prevention such as a strict Content Security Policy.
Are HttpOnly cookies better than localStorage for JWT?
They protect against a specific threat: JavaScript cannot read an HttpOnly cookie, so injected script cannot steal the token. In exchange you must defend against CSRF, using SameSite, Origin checks and CSRF tokens. XSS can still make requests as the user in either approach, so neither replaces fixing XSS.
What does SameSite do on a cookie?
SameSite controls whether the browser sends the cookie on cross-site requests. Strict never sends it cross-site, Lax sends it only on top-level navigations such as link clicks and not on cross-site fetches or form posts, and None sends it always (but requires Secure). It is a strong CSRF mitigation but should not be your only one.