DEV Community

Cover image for I built JWT auth with refresh token rotation. I'd still choose sessions.
Vaggelis Tzironis
Vaggelis Tzironis

Posted on Edited on

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, Fastify and MongoDB. The access token lives in memory and the refresh token in an httpOnly cookie. The guide refreshed before the token expired. I picked what looked simpler. 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. I found pieces of it in docs and gists but no guide that put them together, so I worked it out myself. That one line has to handle all of these.

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. Log in, close the tab and come back within 30 days and you're still logged in, no password needed. Stay away longer and you log in again. Every refresh starts the 30 days over, so if you open the app every week you never see the login page.

The access token goes to every route as a Bearer header and is checked by signature only. The refresh cookie only reaches the auth routes, which read the session in MongoDB.

Login returns the access token in the response body and sets the refresh token as a cookie. Every API call sends the access token. When it expires the API answers 401 and the app calls POST /api/v1/auth/refresh. The browser attaches the cookie by itself and the server answers with a new access token and a new refresh token. Then the app retries the request that failed.

Login, a normal request, a 401 after 3 minutes, a refresh that returns a new access token and a new refresh cookie, then the retry.

Every refresh also rotates the token, so the old refresh token stops working. This is how you catch theft. If someone copies your refresh token, two people now hold it. Whoever refreshes first gets a new token. Whoever refreshes second 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.

You refresh with RT1 and get RT2. Minutes later the thief sends the copied RT1. The server sees an old token and revokes the session, so your next refresh with RT2 fails too.

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 and a naive interceptor starts ten refreshes, all with the same cookie. The first one replaces the token. The other nine arrive with the token it just replaced.

On a server with plain rotation that's the classic race. The second refresh shows a token the server already replaced, so it counts as reuse and the session is revoked. That refresh gets a 401 of its own. The interceptor reads it as "session over" and you're logged out a few milliseconds after the page opened.

Ten requests get a 401 and a naive interceptor starts ten refreshes with the same cookie RT1. The first one rotates it to RT2. The second shows RT1 again, so the server sees reuse and revokes the session. That refresh gets a 401 of its own and the interceptor logs you out.

Mine gives the previous token a few seconds of grace, more on that below, so the nine get through. But each of them 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 3 minutes later instead of right away.

Without a queue ten 401s start ten refreshes and the browser can end up with a different cookie than the database. With a queue one request refreshes while the other nine wait and retry with the new token.

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)); // retryWith also sets _retry
  }

  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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  • _retry stops 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 before the refresh reaches the server fails one request and you stay logged in.

A 401 that lands just after the refresh finished can still start a second one. It sends the new cookie, so it only costs one extra rotation.

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());
}
Enter fullscreen mode Exit fullscreen mode

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.

So three tabs cost three rotations. The first tab could pass its new access token to the others over the BroadcastChannel and save them the refresh. I left it out. An extra rotation is one database write and the lock already makes it safe.

Tab A turns RT1 into RT2 while B and C wait for the lock. B then sends RT2 and C sends RT3.

Pressing F5 at the wrong moment

Pressing F5 during a refresh doesn't log you out, as long as the reloaded page asks for a new token 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.

The response with RT2 is lost because of the reload. The page sends RT1 again within 10 seconds. The server accepts it as the previous token and answers with RT3.

Each check and replace is a single 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 };
Enter fullscreen mode Exit fullscreen mode

Inside those 10 seconds the previous token works more than once. Every use writes a new token and only the newest of those stays valid. The retry also leaves rotatedAt alone. Otherwise an old token could keep its window open forever.

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's there for a lost response. Ten refreshes inside it still race each other for the cookie.

Refreshing on a timer before the token expires doesn't remove any of this. Browsers throttle timers in background tabs and a sleeping laptop runs none, so it wakes up with every token expired and the 401 path still has to work. Restored tabs start their timers together. A timer can fire while a 401 refresh is running. A reload cuts a timer's refresh like any other. So the queue, the lock and the grace window all stay.

And the timer adds a problem. An idle tab refreshes every 3 minutes, so the session never expires while the tab is open. Refreshing on a 401 only renews the session when someone uses the app.

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';
};
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

A hidden form on evil.example posts to the refresh route. The request goes out with Sec-Fetch-Site set to cross-site and no cookie, so the server answers 403. The routes that act as you need a Bearer header that a form can't send.

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 other routes that act as you can't be hit this way at all, because they need 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 a logout 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.

After a logout from all devices, other tabs log out right away through BroadcastChannel. A phone keeps using its access token for up to 3 minutes until it gets a 401.

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.

An injected script can't read the cookie but it can call the refresh route from the same origin. Every CSRF check passes and it gets a fresh access token.

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. The same goes for a thief who refreshes before the victim does. If the victim never refreshes again, nothing catches them, because every refresh pushes the 30 days forward and there's no absolute limit.

The thief sends the copied RT1 within 10 seconds of your refresh and gets RT3. RT2 is now neither the current nor the previous token, so your next refresh revokes the session and RT3 dies with it.

If my token was stolen I would never know. I'd see the same "session expired" message as after a logout from another device. 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 have to cover every route that changes something. The cost is one indexed read per request, which almost any app can afford. Sessions have chores of their own. The id has to change at login so nobody can plant one on you and the store has to keep up when you scale. What I give up is theft detection. A stolen session id keeps working until it expires or you log out. Rotation would cut the thief off at your next refresh, though you'd never know it happened. The IETF published its best current practice for OAuth in 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.

Everything my setup needed next to what a classic session needs.

Hasura has since rewritten the guide. It now keeps both tokens in sessionStorage with a fingerprint cookie. It mentions single-use refresh tokens only for SSR and doesn't get into 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


The code is on GitHub and the app runs at auth-app.vaggelistzironis.tech.

The app's UI comes from the Authentication App challenge on the old devchallenges.io. The challenge is gone from the site now but the archived page still shows it.

Top comments (0)