The JWT + HTTP-only cookie auth I reuse on every project
The default React tutorial pattern is to store the JWT in localStorage. That makes XSS catastrophic. This is the auth setup I now reuse on every project — HTTP-only cookies, refresh-token rotation, SameSite for CSRF — built originally for NeoMail and copy-pasted (with adjustments) into every dashboard since.
Why localStorage for JWTs is wrong
The common pattern looks fine. Login returns a JWT. The frontend writes it to localStorage. Every subsequent request attaches it as Authorization: Bearer <token>. The server verifies and proceeds.
The problem: any JavaScript that runs in your origin can read localStorage. A single XSS — one unescaped user comment that contains <script>, one compromised npm dependency, one careless dangerouslySetInnerHTML — gives the attacker the user's full session token. They can copy it, hit your API from their own machine, and the server has no way to know the request isn't legitimate.
XSS in 2026 is not exotic. It's the #1 OWASP web vulnerability for a reason. Treating "we'll just be careful" as your defence is naive. The correct architectural answer is: the token must not be readable from JavaScript at all.
The fix: HTTP-only cookies
HTTP-only cookies cannot be read from JavaScript. document.cookie doesn't return them. fetch can't enumerate them. Only the browser knows they exist, and only when it auto-attaches them on requests to the matching origin.
The set:
// POST /api/auth/login
res.cookie('access_token', accessJwt, {
httpOnly: true, // not readable from JS
secure: true, // HTTPS only
sameSite: 'lax', // CSRF mitigation; 'strict' if no third-party redirects
path: '/',
maxAge: 15 * 60 * 1000 // 15 minutes
});
res.cookie('refresh_token', refreshJwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/api/auth/refresh', // only sent to the refresh endpoint
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
The access token is short-lived (15 minutes). The refresh token is long-lived (30 days) but only attached to the single endpoint that uses it. If the access token is ever exposed via a server-side log or a misconfigured response header, the damage window is bounded.
The refresh flow
When the access token expires, the frontend's next API request gets a 401. The frontend then calls POST /api/auth/refresh (which the refresh cookie auto-attaches to). The server validates the refresh token, issues a new pair, and the frontend retries the original request.
// fetch wrapper — frontend
async function api(path, opts = {}) {
const r = await fetch(path, { ...opts, credentials: 'include' });
if (r.status !== 401) return r;
// attempt refresh, then retry once
const refreshed = await fetch('/api/auth/refresh', {
method: 'POST', credentials: 'include'
});
if (!refreshed.ok) {
window.location.href = '/login';
return r;
}
return await fetch(path, { ...opts, credentials: 'include' });
}
The important bit is credentials: 'include' on every fetch. Without it, the browser won't send the cookies cross-origin. Forget this and the API will see no auth at all, throw 401 forever, and you'll waste an afternoon debugging "why isn't my refresh working" before realising. Speaking from experience.
Refresh-token rotation
A long-lived refresh token is the soft underbelly. If it ever leaks, the attacker has 30 days of access. Mitigation: rotate it on every use.
Every call to /api/auth/refresh issues a brand-new refresh token and invalidates the old one. The server keeps a small table of valid refresh tokens (or, more elegantly, a hash + version counter on the user row). If a refresh token is presented that the server has already rotated past, the user's entire session is invalidated — which means an attacker who stole a token and used it once also locks themselves out the moment the legitimate user's next refresh fires.
// POST /api/auth/refresh
async function refresh(req, res) {
const token = req.cookies.refresh_token;
if (!token) return res.sendStatus(401);
const payload = jwt.verify(token, REFRESH_SECRET); // throws on invalid
const user = await db.user.findById(payload.sub);
if (!user || user.refresh_token_version !== payload.ver) {
// token has been rotated past — invalidate everything
await db.user.bumpRefreshVersion(user.id);
return res.sendStatus(401);
}
// rotate
const newVer = user.refresh_token_version + 1;
await db.user.update(user.id, { refresh_token_version: newVer });
res.cookie('access_token', signAccess(user), ACCESS_OPTS);
res.cookie('refresh_token', signRefresh(user, newVer), REFRESH_OPTS);
res.sendStatus(204);
}
CSRF: the trade-off SameSite makes
HTTP-only cookies eliminate the XSS risk and reintroduce CSRF as the attack vector to worry about. CSRF is "any other website can trigger requests to your API as the logged-in user" because the browser auto-attaches cookies.
SameSite=Lax blocks cookies on cross-site requests for everything except top-level navigation (which is mostly safe). It eliminates ~95% of CSRF concerns out of the box. For the remaining 5% (state-changing GETs from same-site iframes, etc.), add a CSRF token on state-changing routes — a random value sent in a separate header that the legitimate frontend reads from a non-HTTP-only cookie. MDN's overview is the canonical reference.
For most React + Node dashboards, SameSite=Lax alone is enough. Add the CSRF token if you're handling money movements, account changes, or anything where one malicious request matters.
Three gotchas that always bite
- Cross-origin in dev. If your frontend runs on
localhost:5173and backend onlocalhost:4000, that's cross-origin and the browser will refuse to send the cookies. SetSameSite=None; Securein dev (you'll need HTTPS even locally;mkcerttakes 10 minutes), or proxy the API through Vite/Webpack to make it same-origin. secure: truein dev. Set this in production only. In dev over plain HTTP, the cookie won't be set at all — silent failure.- Multiple tabs. If a refresh fires from tab A while tab B has a stale access token, tab B's next request will 401 and try to refresh — but the refresh token has already rotated. Mitigate: when 401 → refresh fails, check the response headers to distinguish "session genuinely expired" from "rotated past." Usually best handled by a shared SharedWorker, or just accept the occasional re-login on tab B.
Where this came from
The first complete version of this setup was built for NeoMail — a MERN email client where token theft would mean reading someone's inbox. The case study is on the work page. The same auth code, with the database swapped from MongoDB to PostgreSQL, now sits in every finance dashboard I've shipped since.
The trade-off is honest: HTTP-only cookies are slightly more complex than localStorage + Bearer tokens. There's no Bearer token to paste into Postman; you debug via DevTools Network → Cookies. The win is that one XSS doesn't become full account takeover. For any app that handles real data, that trade-off is correct.
Building a React + Node app and want this set up properly? Gauravthakur1k@gmail.com · Services →