JWT vs HttpOnly Cookie Sessions: Building Bulletproof Authentication for Full-Stack Applications
Designing bulletproof full-stack authentication: short-lived in-memory JWT access tokens, HttpOnly refresh cookies, transparent Axios interceptors, and CSRF protection.
By Uttam Thapa · · Security
⚡ Executive Summary (TL;DR)
A token in localStorage is readable by every script on the page, so one XSS becomes full account takeover. The pattern that
holds up in production is a short-lived access token kept in memory, paired with a refresh token in an HttpOnly, Secure,
SameSite cookie — and, because cookies are sent automatically, a CSRF defence to go with it.
Figure 1: The two attacks pull in opposite directions — fixing one badly is how you create the other.
The Two Attacks That Shape Every Decision
Authentication storage is a trade between two threats, and almost every bad design comes from optimising for one while forgetting the other.
XSS — Cross-Site Scripting
Untrusted JavaScript runs on your page — via a dependency, a user-generated field, or an injected script. If the token is anywhere JavaScript can read it,
one line exfiltrates it: localStorage.getItem('token'). Session length no longer matters; the attacker has the credential.
CSRF — Cross-Site Request Forgery
Another site causes the browser to send a request to yours. Because cookies attach automatically, the request arrives authenticated even though the
attacker never read anything. This threat exists because of cookies, which is why moving to them is not a free win.
🚨 The trade nobody mentions
Tokens in JavaScript-readable storage are immune to CSRF and wide open to XSS. Cookies are immune to XSS theft and exposed to CSRF. There is no storage
location that avoids both, so the correct design picks the cookie and then adds a CSRF defence — rather than claiming immunity it does not have.
JWT or Session: What Actually Differs
The debate is usually framed as security, but the real difference is revocation. A server-side session is a row you can delete. A stateless JWT is valid until
it expires, because verifying it involves no lookup — which is exactly the property that makes it fast and the property that makes it hard to withdraw.
|
Server session |
Stateless JWT |
| Verification | Store lookup per request | Signature check, no I/O |
| Revocation | Immediate — delete the row | Not until expiry, without a denylist |
| Horizontal scale | Needs a shared store | Nothing shared |
| Permission changes | Take effect at once | Stale until the token refreshes |
| Best fit | One app with its own database | Several services verifying independently |
Note what happens when a stateless JWT gets a denylist to make revocation work: every request now checks a shared store, which is a session with extra steps.
If you need instant revocation, choose sessions deliberately rather than arriving there by accident.
The Split-Token Pattern
The design that gets the best of both: a short-lived access token that never touches persistent storage, and a long-lived refresh token the browser can send but
JavaScript cannot read.
// The refresh token goes in a cookie JavaScript cannot touch.
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // unreadable from JavaScript — this is the XSS defence
secure: true, // HTTPS only
sameSite: 'strict',
path: '/api/auth/refresh', // sent only to the refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// The access token is returned in the body and held in memory only.
return res.json({ accessToken }); // expires in ~15 minutes
Scoping the cookie with path matters more than it looks: the refresh token is then attached only to refresh calls, so it is not sent with — and
cannot be leaked by — every ordinary API request.
On the client, hold the access token in a module variable or React state, never in localStorage. An interceptor catches a 401, calls the refresh
endpoint, and replays the original request. The window during which a stolen access token is useful is then measured in minutes rather than days.
Where SameSite Ends and CSRF Defence Begins
SameSite=Strict stops the browser attaching the cookie to cross-site requests, which removes the classic CSRF vector. It is a strong default and it
is not a complete answer.
Strict
Never sent cross-site — including when a user follows a link from an email into your app, which logs them out.
Lax
Sent on top-level GET navigations. Usually the right balance, but it leaves GET-triggered state changes exposed.
None
Required for genuine cross-origin setups, and it removes the protection entirely. Pair with an explicit CSRF token.
Two rules follow. Never change state on a GET request — if a link can mutate data, SameSite=Lax will happily allow it. And if your frontend and API
are on different origins, you are on SameSite=None and need a real CSRF token, because the cookie attribute is doing nothing for you.
💡 Rotate refresh tokens
Issue a new refresh token on every refresh and invalidate the previous one. If an old token is ever presented again, that is evidence of theft — revoke the
whole family and force a re-login. It turns a silent compromise into a detectable event.
✅ Key takeaways
- ✓Never put a token in
localStorage. One XSS is then one line away from account takeover.
- ✓Cookies trade XSS risk for CSRF risk. Take the trade, then defend the other side.
- ✓The real JWT question is revocation. A denylist to fix it is a session wearing a costume.
- ✓Scope the refresh cookie by
path. It should reach one endpoint, not every request.
- ✓Never mutate state on GET. It is what makes
SameSite=Lax safe.
- ✓Rotate refresh tokens. Reuse of an old one is your theft alarm.
Continue with security practices every web application needs and
role-based access control in Node.js and PostgreSQL for what happens after
the user is authenticated.
Frequently asked questions
Should I store a JWT in localStorage?
No. Anything in localStorage is readable by any script on the page, so a single XSS becomes full account takeover. Use an HttpOnly cookie, which JavaScript cannot read.
Are JWTs less secure than sessions?
Not inherently, but they are harder to revoke. A session can be deleted server-side and is instantly invalid; a stateless JWT stays valid until it expires unless you maintain a denylist — which reintroduces the state you avoided.
When are stateless JWTs the right choice?
When multiple services must verify identity without a shared session store, and short expiry with a refresh flow is acceptable. For a single application with its own database, session cookies are simpler and easier to revoke.
Home · Projects · Blog · Services · Résumé · Contact