Where should a browser app keep its tokens? Not localStorage (mostly)
XSS, CSRF, HttpOnly cookies and the backend-for-frontend pattern, without the religious war.
Contents
This question comes up in every code review I do for a new web app, so I am writing the long answer once.
The short version
For a first-party web app (your frontend talks to your own API): keep the session in an HttpOnly, Secure, SameSite cookie, and do not put long-lived tokens anywhere JavaScript can read them. If you must use OAuth access tokens in the browser, keep the access token in memory and short-lived, and the refresh token in an HttpOnly cookie. Or better, keep both on the server with a backend-for-frontend.
Why not localStorage
Anything in localStorage or sessionStorage is readable by any JavaScript running on your origin. One XSS bug, in your code or in one of the 900 packages in your node_modules, and the attacker reads the token and sends it to their server. Now they can use it from anywhere for as long as it is valid. If it is a refresh token, that can be weeks.
People answer: "if you have XSS you already lost". Partly true, and I will come back to it. But there is a difference between an attacker who can act inside the victim's open tab for a few minutes and an attacker who has a token in their own database to use for 30 days.
Why HttpOnly cookies
An HttpOnly cookie is sent automatically by the browser and cannot be read by JavaScript. XSS can still make requests from the page while the user has it open, but it cannot exfiltrate the credential. Add:
Secure, so it only goes over HTTPSSameSite=Lax(or Strict), which blocks the cookie on most cross-site requests and removes most of the classic CSRF risk- the
__Host-prefix on the name, which forces Secure, Path=/ and no Domain attribute, so a subdomain cannot overwrite it
Cookies bring CSRF back into the picture, so for state changing requests keep a CSRF token or at least verify the Origin header. SameSite=Lax does not protect GET requests that change state, so do not have those.
The backend-for-frontend pattern
If you integrate with an OAuth provider, the cleanest setup is a small server component (the BFF) that runs the OAuth flow, keeps the access and refresh tokens on the server, and gives the browser only a session cookie. The browser never sees an OAuth token at all. Most frameworks people use today already have a server, so this is less work than it sounds.
About "XSS means game over"
Yes, XSS is very bad whatever you do. So also do the things that make XSS less likely and less useful:
- A Content Security Policy without
unsafe-inlinefor scripts - Framework escaping, and a review of every place you bypass it (dangerouslySetInnerHTML, v-html, innerHTML)
- Short-lived access tokens, so a stolen one expires quickly
- Refresh token rotation with reuse detection: if an old refresh token is used again, revoke the whole family
Storage choice is one layer. It is not the only one, but it is cheap to get right at the start and expensive to change later.
Happy to look at specific setups in the comments, but please do not paste real tokens. Yes, it happened.
Written by
Fatima Al-Sayed
Application security engineer at a bank in Dubai. Threat modelling, auth flows, secure code review. I will ask where your JWT secret lives. Arabic coffee enthusiast.
Get the best of #security-theatre weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment