Threat model in one paragraph
Access tokens in localStorage are trivial to steal if any XSS lands. Tokens in memory-only JS are safer but disappear on refresh. httpOnly cookies keep tokens off document.cookie, and Secure plus SameSite reduce drive-by sends. XSS can still trigger authenticated requests from the victim's browser — cookies are not magic — but they stop simple token exfiltration scripts.
I use a short-lived access JWT of 5 to 15 minutes and a longer refresh token of 7 to 30 days that we rotate and store hashed server-side. Stolen refresh tokens should not live forever.
Our pen-test writeup put it bluntly: with localStorage JWTs, a single reflected XSS stole sessions from three test accounts in under a minute. With httpOnly cookies, the same payload could still act as the user but could not exfiltrate a reusable bearer token to an attacker laptop. That trade is worth the CSRF work.
Issuing tokens
On login I verify password with bcrypt at cost 12, then mint an access JWT signed with HS256 or better RS256 or EdDSA in larger systems. Claims stay minimal: sub, role, iat, exp. No PII blobs. Refresh token is a random 32-plus byte string; we store sha256 of the token in Postgres with user_id, expires_at, replaced_by, and revoked_at.
Clock skew between API instances bit us once: tokens looked expired 30 seconds early. We allow a 30-second clockTolerance on verify, and we NTP-sync the fleet. Also rotate JWT_ACCESS_SECRET only with a dual-key verify window so deploys do not log everyone out at once.
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const ACCESS_TTL = '10m';
const REFRESH_DAYS = 14;
export function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: ACCESS_TTL }
);
}
export function createRefreshToken() {
return crypto.randomBytes(48).toString('base64url');
}
export function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export function setAuthCookies(res, accessToken, refreshToken) {
const common = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
};
res.cookie('access_token', accessToken, { ...common, maxAge: 10 * 60 * 1000 });
res.cookie('refresh_token', refreshToken, {
...common,
path: '/auth',
maxAge: REFRESH_DAYS * 24 * 60 * 60 * 1000,
});
}
Middleware that reads the cookie
I do not accept access tokens from query strings. Cookie or Authorization Bearer for API clients. Cookie path scoping for refresh under /auth limits accidental sends to every asset request — a mistake I made when both cookies shared path slash and every image request carried a refresh token.
Query-string tokens also end up in access logs, referrer headers, and browser history. We banned them in a security review after finding tokens in CDN logs.
import cookieParser from 'cookie-parser';
app.use(cookieParser());
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
const token =
(header && header.startsWith('Bearer ') && header.slice(7)) ||
req.cookies.access_token;
if (!token) return res.status(401).json({ error: 'unauthenticated' });
try {
req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
next();
} catch {
return res.status(401).json({ error: 'invalid_token' });
}
}
Refresh with rotation
POST /auth/refresh reads the refresh cookie, hashes it, looks up a non-revoked row, issues a new access plus refresh pair, marks the old refresh as replaced, and sets cookies again. If a previously replaced refresh is presented again, I revoke the whole family — classic reuse detection for token theft.
Without rotation, a leaked refresh token works until expiry. With rotation and reuse detection, we cut session abuse windows from days to minutes in one incident response drill.
Race condition note: two parallel tabs can refresh at the same millisecond. One wins; the other looks like reuse. We solve this with a short grace window of about 10 seconds where the previous refresh still works if replaced_by was set recently by the same family, or by serializing refresh with a per-user lock.
app.post('/auth/refresh', async (req, res) => {
const raw = req.cookies.refresh_token;
if (!raw) return res.status(401).json({ error: 'no_refresh' });
const row = await db.refreshTokens.findByHash(hashToken(raw));
if (!row || row.revoked_at || row.expires_at < new Date()) {
return res.status(401).json({ error: 'invalid_refresh' });
}
if (row.replaced_by) {
await db.refreshTokens.revokeFamily(row.family_id);
return res.status(401).json({ error: 'reuse_detected' });
}
const user = await db.users.findById(row.user_id);
const access = signAccessToken(user);
const nextRefresh = createRefreshToken();
await db.refreshTokens.rotate(row.id, hashToken(nextRefresh), REFRESH_DAYS);
setAuthCookies(res, access, nextRefresh);
res.json({ ok: true });
});
Silent refresh on the client
The SPA keeps a fetch interceptor: on 401, try /auth/refresh once, retry the original request, and if refresh fails redirect to login. Without a single-flight mutex, five parallel 401s spawn five refreshes and trigger reuse detection. I gate refresh behind a shared promise.
let refreshPromise = null;
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
}).finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function apiFetch(input, init) {
const res = await fetch(input, { ...init, credentials: 'include' });
if (res.status !== 401) return res;
const refreshed = await refreshOnce();
if (!refreshed.ok) throw new Error('session_expired');
return fetch(input, { ...init, credentials: 'include' });
}
CSRF when cookies are involved
Cookie auth reintroduces CSRF for state-changing requests. I use SameSite=Lax as a good default for most apps plus a double-submit CSRF token for mutating routes, or SameSite=Strict for sensitive admin tools that can tolerate harsher UX.
SPA on app.example.com and API on api.example.com needs careful cookie Domain and CORS credentials. Same-site under a parent domain is simpler when you can afford it.
Cookie attributes I verify in staging
Before calling a release done I open Application Cookies on staging and confirm: httpOnly checked, Secure on HTTPS, SameSite as designed, path scoped for refresh, and expiry matching TTL. I have shipped a missing Secure flag that worked on localhost HTTP and silently dropped cookies in production HTTPS redirects.
Cross-subdomain cookies need Domain=.example.com carefully. Too broad and every subdomain receives the session cookie. For native mobile clients we still issue Bearer access tokens in the JSON body and skip cookies. The same signing and refresh tables back both clients.
I log refresh outcomes as counters: success, expired, reuse_detected, missing. A spike in reuse_detected without a security incident usually means the client double-refresh race. Never log raw tokens — log a truncated hash prefix if you need correlation.
Mistakes I will not repeat
Storing refresh tokens plaintext in the DB. Using a 7-day access JWT to avoid refresh complexity. Putting secrets in the JWT payload and expecting them to stay secret. Forgetting to clear cookies on logout while only deleting the client store.
Logout must clear cookies with res.clearCookie and revoke the refresh row. For force-logout-all-devices, revoke all refresh families for that user_id. We expose that as a security settings action. Document the token TTLs in the security runbook so on-call can answer session questions without reading code at 2am.
- Access JWT: minutes, not days
- Refresh: random opaque token, hashed at rest, rotated
- httpOnly plus Secure plus scoped path
- Reuse detection on refresh
- Logout revokes server-side state, not only cookies
- Single-flight client refresh to avoid false reuse alarms
Key takeaways
- Prefer httpOnly cookies over localStorage for browser session tokens.
- Keep access JWTs short-lived; persist opaque refresh tokens hashed in the DB.
- Rotate refresh tokens and revoke the family on reuse.
- Cookie auth needs a CSRF strategy with SameSite plus tokens for mutations.
- Logout and sign out everywhere must revoke server-side refresh rows.
- Serialize client silent-refresh so parallel 401s do not look like theft.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.