There's no perfect answer, only tradeoffs Every storage location for a client-side JWT trades one class of attack for another. The right choice depends on your app's architecture (SPA vs server-rendered), your exposure to XSS, and whether you can control cookie attributes end to end.
localStorage: convenient, XSS-exposed Storing a JWT in `localStorage` is the path of least resistance for SPA developers — it's trivial to read and attach to an `Authorization` header. The problem is that **any JavaScript running on your page can read it**, including malicious scripts injected via a cross-site scripting (XSS) vulnerability, a compromised third-party dependency, or a malicious browser extension. If your app is vulnerable to XSS anywhere, tokens in localStorage are trivially exfiltrated.
HttpOnly cookies: XSS-resistant, CSRF-exposed A cookie marked `HttpOnly` cannot be read by JavaScript at all — a huge win against XSS-based token theft. But cookies are sent automatically by the browser on matching requests, which opens the door to **cross-site request forgery (CSRF)**: a malicious site can trigger a request to your API and the browser will attach the cookie, even though the request didn't originate from your app.
The mitigations are well understood:
- Set SameSite=Strict or SameSite=Lax to stop most cross-site cookie submission.
- Use a CSRF token pattern (double-submit cookie or synchronizer token) for state-changing requests.
- Always set Secure so the cookie is never sent over plain HTTP.
In-memory storage: safest, least persistent Keeping the access token only in a JavaScript variable (never localStorage, never a cookie) means it disappears on page refresh and can't be read by an XSS payload that runs after the page reloads — but a payload running **during** the same page load still can, since it shares the JS runtime. It also means you need a silent refresh mechanism to reacquire the token after a reload, typically via an HttpOnly refresh-token cookie.
The pattern most production apps converge on - **Access token**: JWT, short-lived, kept in memory only, never persisted to storage. - **Refresh token**: opaque, long-lived, stored in an HttpOnly, Secure, SameSite cookie, used only against a dedicated refresh endpoint.
This combination means an XSS payload can steal at most a short-lived access token if it manages to read memory during execution, but cannot read the refresh token at all, and a CSRF attempt against the refresh endpoint fails without a valid double-submit token.