I built JWT auth with refresh token rotation. I'd still choose sessions.
In 2021 every auth video I watched turned into JWT vs sessions. Half of those YouTubers linked the same article, Hasura's guide to handling JWTs on frontend clients, so I built what it said in a side project with React,
In 2021 every auth video I watched turned into JWT vs sessions. Half of those YouTubers linked the same article, Hasura's guide to handling JWTs on frontend clients, so I built what it said in a side project with React, Fastify and MongoDB. The access token lives in memory and the refresh token in an httpOnly cookie. On a 401 you refresh and retry.
That last sentence turned into a promise queue, a lock between tabs, a grace window on the server and CSRF checks. That one line has to handle all of these.
- Ten requests get a 401 at the same moment. Do you refresh ten times?
- Three tabs expire in the same second. Which one refreshes?
- I press F5 while a refresh is in flight. Am I logged out?
- The refresh endpoint reads a cookie. What stops another site from calling it?
How it's set up
The access token is a JWT that lives 3 minutes. It only exists in JavaScript memory and goes out in the Authorization: Bearer header. The API checks its signature to know who you are, without asking the database. That's the selling point of JWTs.
The refresh token is a JWT in an httpOnly cookie, so JavaScript can't read it. It's stateful. Every login creates a session document in MongoDB and the token only works while that document says so. The session lasts 30 days from the last refresh.
Every refresh also rotates the token. You get a new refresh token and the old one stops working. This is how you catch theft. If someone copies your refresh token, two people now hold it. The next time either of you refreshes, one of them shows up with a token the server already replaced. The server treats that as theft and revokes the whole session. The attacker is out and so are you.
The 401 that looks simple
With a queue, ten 401s trigger a single refresh. Without one, they trigger ten and can log you out.
"On a 401, refresh and retry" reads like three lines in an interceptor. But requests don't fail one at a time. Open a page after the access token expired and ten requests go out together. All ten come back 401 within a few milliseconds. A naive interceptor starts ten refreshes. The first one replaces the token. The other nine arrive with the token it just replaced. The server gives the previous token a few seconds of grace, more on that below, but each of those nine still writes a new token and sends back a different cookie. The browser keeps whichever one lands last. If that isn't the one in the database, your next refresh looks exactly like a stolen token and the session dies.
So only one request may refresh. The rest get a promise that waits in a queue until the new token arrives. This is simplified from the real interceptor.
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// a refresh is already running. Park this request until it finishes
return new Promise((resolve, reject) => failedQueue.push({ resolve, reject }))
.then((token) => retryWith(originalRequest, token));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await renewTokens();
addAuthorizationHeader(newToken); // later requests use it too
processQueue(null, newToken); // resolve every parked promise with the new token
return retryWith(originalRequest, newToken);
} catch (refreshError) {
processQueue(refreshError, null); // reject every parked promise
const status = isAxiosError(refreshError) ? refreshError.response?.status : undefined;
if (status === 401 || status === 403) {
// the server said the session is over
endSession();
broadcastAuthEvent('logout'); // tell the other tabs
}
throw refreshError;
} finally {
isRefreshing = false;
}
}
The first 401 becomes the leader and does the refresh. Every 401 that arrives while it's running waits for it. Each of the other lines fixes a bug.
- If the refresh fails, the waiting requests must fail too. Otherwise they hang behind a spinner forever.
-
_retrystops a retried request from starting yet another refresh. - The new token becomes the default header. Otherwise the next click goes out with the old one and the refresh starts again.
- The retry keeps the request's own config and swaps only
Authorization, so a file upload isn't resent as JSON. - Only a 401 or 403 from the refresh ends the session. A Wi-Fi blip fails one request and you stay logged in.
A 401 that lands just after the refresh finished can still start a second one. The lock in the next section makes that harmless.
Then there are tabs
All three tabs refresh, one after another. Each tab has to refresh because its access token lives only in its own memory. They just can't do it at the same time, because they all share one cookie. Two tabs that expire together would each run their own leader and you're back to the same race.
Tabs of the same origin can share a lock through the Web Locks API.
export async function renewTokens(): Promise<string> {
if (!navigator.locks) return requestNewTokens();
return navigator.locks.request('auth-refresh', () => requestNewTokens());
}
Each tab waits for the lock and sends whatever cookie the previous tab left behind. The request has a 20 second timeout so a hung refresh can't hold the lock forever.
Pressing F5 at the wrong moment
No, as long as the reload finishes within 10 seconds.
The browser sends a refresh, the server rotates the token, then I reload the page before the response with the new cookie gets back. My browser still holds the old token. Under the rotation rule that's theft and I'm logged out for reloading a page.
So the server accepts the previous token for 10 more seconds. Each token carries the session id as sid so the server knows which document to check. The document keeps a SHA-256 hash of the current token and the one before it, so a database leak hands out nothing that works.
Checking the token and replacing it is one updateOne whose filter contains the token I expect. An old token can never be accepted as the current one and a session revoked a millisecond earlier can't be rotated.
const sessionFilter = { _id: sid, userId, revoked: false, expiresAt: { $gt: now } };
// 1. It's the current token. Rotate it and remember it as previousToken
const rotated = await refreshTokens.updateOne(
{ ...sessionFilter, token: presentedHash },
{ $set: { previousToken: presentedHash, token: newHash, rotatedAt: now, expiresAt, ip, userAgent } }
);
if (rotated.modifiedCount === 1) return { success: true };
// 2. It's the previous token, rotated less than 10 seconds ago. The response got lost
const retried = await refreshTokens.updateOne(
{ ...sessionFilter, previousToken: presentedHash, rotatedAt: { $gt: tenSecondsAgo } },
{ $set: { token: newHash, expiresAt, ip, userAgent } }
);
if (retried.modifiedCount === 1) return { success: true };
// 3. Anything else is an old token of this session. The caller revokes it
// (every JWT also gets a random jti, otherwise two refreshes in one second would produce the same token)
throw { customError: Errors.RefreshTokenReused };
The reload, the app starting up and the next refresh all have to fit in those 10 seconds. On a slow connection they might not.
The grace window doesn't replace the queue on the client. It only saves one lost response. Ten refreshes inside it still race each other for the cookie.
The cookie that anyone could make you send
Two checks stop another site from using the refresh cookie. The interesting one runs before the token is even read.
export const isRequestFromSameOrigin = (request: FastifyRequest): boolean => {
const fetchSite = request.headers['sec-fetch-site'];
if (fetchSite === undefined) return true; // curl or an old browser. SameSite still applies
return fetchSite === 'same-origin';
};
Browsers set Sec-Fetch-Site to say where a request came from and page scripts can't change it. The check runs before the token checks on every route that reads the cookie. That matters because a failed refresh clears the cookie. Without it another site could log you out just by sending a refresh that fails.
It also asks for same-origin on purpose. SameSite treats old-forgotten.your-app.com as the same site and this check doesn't.
The other check is the cookie itself.
export const generateCookieOptions = (token: string) => ({
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: secondsUntilExpiration(token), // seconds, taken from the token's own exp
path: '/api/v1/auth',
});
With SameSite=Strict a request that starts on another site doesn't carry it. Strict doesn't hurt here because the cookie is only ever sent by the app's own fetch calls, never on a link click. The path means only the auth routes ever receive it.
This is all another site needs to try.
<!-- on evil.example -->
<form action="https://your-app.com/api/v1/auth/refresh" method="POST"></form>
<script>document.forms[0].submit()</script>
The Same-Origin Policy and CORS only control who reads the response. A form post never triggers a preflight, so the browser just sends it. With the two checks it goes out without the cookie and the server answers 403 before it even looks for one. The rest of my API can't be hit this way at all, because normal requests carry the token in the Bearer header and the browser never adds that by itself.
I skipped the classic CSRF token. It's one more piece of state to sync across tabs and rotations. My app and API share an origin, so SameSite=Strict costs me nothing. If yours are cross-site or you need old browsers, use a token.
Logging out isn't instant
Logout revokes one session in the database. Logout from all devices and a password reset revoke all of them. Other tabs in the same browser hear about it right away through a BroadcastChannel.
Other devices find out much later. They hold an access token and nobody checks the database for it, so they keep working for up to 3 minutes. Making logout instant means checking the session on every request and then the access token isn't stateless anymore. The access token's lifetime is how long a logout takes to reach other devices. That's why mine lives 3 minutes.
What none of this protects you from
XSS beats all of this. A script on your page is same-origin. It can't read the cookie but it can call refresh and the API exactly like the app does. React escapes what I render in JSX and the app never uses dangerouslySetInnerHTML. Production also sends a CSP with script-src 'self', so injected inline or third-party scripts don't run.
A thief who uses a stolen refresh token inside the 10 second window isn't caught on the spot. They get caught the next time the victim refreshes. If that never happens, nothing catches them, because every refresh pushes the 30 days forward and there's no absolute limit.
If my token was stolen I would never know. I'd see the same "session expired" message as any other logout. Only the server log shows a reuse error.
The cookie also keeps you logged in after the browser closes. OWASP's JWT cheat sheet counts a token that survives a browser restart as a weakness.
So, JWT or sessions?
The refresh token I built is a server-side session. It's one document per login that the server checks on every refresh and can revoke. The access token is a 3 minute cache of "this session is still valid". That cache cost me the promise queue, the lock between tabs, the grace window and the logout delay. Since the refresh token lives in a cookie I needed CSRF protection anyway. And I'm no more secure against XSS than with a plain session cookie.
A classic session is an httpOnly, Secure, SameSite cookie with a random id and a store on the server. Logging out of one device or all of them takes effect on the next request. There is no token in JavaScript and no interceptor queue. The same CSRF checks now cover every route that changes something. The cost is one indexed read per request, which almost any app can afford. The IETF published its best current practice for browser apps in August 2026 as RFC 10017. It puts the Backend For Frontend pattern first. A BFF is a cookie session in front of your tokens.
Hasura has since rewritten the guide. It now keeps both tokens in sessionStorage with a fingerprint cookie and it still skips rotation and the races above. But it said this back in 2021 too and I skipped right past the line.
If you can get away with a session token issued by your favourite monolithic framework, you're totally good to go and probably don't need JWTs!
JWTs make sense when a token has to travel between systems that don't share a database. Services calling each other, or an identity provider issuing tokens to apps. One app talking to its own API doesn't need that.
My JWT setup works. After building all of it I believe in classic session-based auth and that's what I'll use next time.
Further reading
- Hasura, The Ultimate Guide to handling JWTs on frontend clients, where this started for me.
- HTB, What web developers don't understand about CORS, on what the Same-Origin Policy and CORS do and don't do.
- RFC 10017, OAuth 2.0 for Browser-Based Applications, the IETF best current practice (BCP 212) that compares BFF, token-mediating backends and pure browser clients.
- Auth0, Refresh Token Rotation, rotation and family revocation for their product.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.