The question every frontend eventually asks
You've got a JWT access token from your auth server. Where do you put it in the browser? This looks like a small implementation detail, but it directly sets your exposure to the two biggest client-side attacks: XSS (malicious JavaScript running in your page) and CSRF (a forged cross-site request riding the user's session). There is no storage location that's immune to both; the goal is to minimize what an attacker gains from each.
Option 1: localStorage — convenient and risky
localStorage (and sessionStorage) is the default many tutorials reach for: localStorage.setItem("token", jwt), read it back, attach it as an Authorization: Bearer header. It's simple and immune to CSRF, because the browser never attaches it automatically — your code has to.
The problem is XSS. Anything in localStorage is readable by *any* JavaScript running on your origin — including a script injected through a compromised dependency, a vulnerable ad, or an unescaped user string. One XSS bug and the attacker reads your token and exfiltrates it. Because the token is a bearer credential, they can now impersonate the user from anywhere until it expires. For long-lived tokens, that's a serious blast radius.
Option 2: HttpOnly cookies — safer against XSS, exposed to CSRF
Store the token in a cookie flagged HttpOnly, and JavaScript can't read it at all — document.cookie won't show it. That defeats the "XSS steals the token" scenario, which is why cookies are generally safer than localStorage for credentials.
But cookies are attached automatically by the browser on every request to your domain, which reintroduces CSRF: a malicious page can trigger a request to your API and the browser will helpfully include the cookie. You mitigate this with:
- `SameSite=Strict` (or
Lax) so the cookie isn't sent on cross-site requests. - `Secure` so it's only sent over HTTPS.
- A CSRF token pattern for any state-changing request if you support cross-site flows.
Cookies also have a size limit (~4KB) and are sent on every request, which matters for large tokens.
Option 3: in-memory — smallest blast radius
Keep the access token in a JavaScript variable (a module-level variable, a closure, a store) and *nowhere* persistent. It vanishes on refresh, and it's never in localStorage or a readable cookie, so a page reload or a new tab doesn't carry it and there's no persistent store for XSS to scrape at rest.
The catch: it doesn't survive a refresh, so on its own it logs the user out every time they reload. That's where the hybrid comes in.
The 2026 consensus: in-memory access token + HttpOnly refresh cookie
The pattern most security-conscious teams converge on splits the two tokens by risk:
- Access token → in memory. Short-lived (minutes), attached as a
Bearerheader from a variable. If XSS runs, it can only grab a token that expires soon, and can't read it from any persistent store. - Refresh token → HttpOnly, Secure, SameSite cookie. Long-lived, unreadable by JavaScript, sent only to the refresh endpoint. On page load (or when the access token expires) the app calls
/refresh; the browser sends the cookie, the server returns a fresh access token into memory.
Page load ──▶ POST /refresh (HttpOnly refresh cookie sent automatically)
◀── { "accessToken": "..." } ── held in a JS variable only
API call ──▶ Authorization: Bearer <in-memory access token>This gives you the best of each: XSS can't read the long-lived credential (it's HttpOnly), CSRF is contained (SameSite + the refresh endpoint is the only cookie consumer), and a stolen access token is worthless within minutes.
Rules that matter more than the storage choice
- Keep access tokens short-lived. Every storage trade-off gets smaller when the token expires in five minutes.
- Fix XSS at the source. No storage choice saves you if attackers can run arbitrary JS — escape output, set a Content-Security-Policy, and audit dependencies. Storage only limits the *damage*.
- Never put secrets in the token. The payload is readable regardless of where you store it — verify with the JWT decoder.
- Rotate refresh tokens on each use so a stolen refresh token has a short useful life.
For the attacks these choices defend against, see JWT vulnerabilities; for token issuance see OAuth token responses and JWT tokens explained.