← Blog
April 2, 2026 · 9 min

Storing JWTs: Cookies vs localStorage

Where you store a JWT on the client determines your exposure to XSS and CSRF. Here's an honest comparison of cookies, localStorage, and memory.

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.

What about mobile apps? Native apps sidestep this entirely by using the platform's secure storage (Keychain on iOS, Keystore on Android), which isn't accessible to other apps or web contexts. There is no direct analog to XSS in a native app unless you're embedding a WebView.

Checklist - Never store long-lived, sensitive tokens in `localStorage` or `sessionStorage` if you can avoid it. - Use `HttpOnly`, `Secure`, `SameSite` cookies for refresh tokens. - Keep access tokens in memory where possible, and keep their lifetime short regardless. - Pair cookie-based auth with CSRF protection — cookies alone are not enough. - Treat any successful XSS as capable of stealing whatever is reachable from JavaScript, and design your storage accordingly.