Engineering
Refresh Token Rotation Without Breaking Long Sessions
Abhishek Bahukhandi

Most auth tutorials assume a page that lives for thirty seconds. A Taqari interview lives for forty-five minutes to an hour, in one tab, with an audio stream open and test results arriving over a long-lived connection the whole time. That changes which auth decisions are cheap and which ones are expensive, and it is why refresh token rotation turned out to be the easy part.
The hard part was everything around it: picking an access token lifetime that survives a single long answer, keeping a session alive without quietly making logout decorative, and discovering that one of the browser APIs we depend on cannot send an authorization header at all.
Why a long interview breaks the usual auth assumptions
A normal web session is a series of short, independent requests. If one of them fails with a 401, you redirect to login, the user signs in again, and almost nothing is lost. That recovery path is what makes aggressive token expiry painless for most apps.
An interview has no such path. The candidate is mid-sentence on a system design question, the microphone is live, a submission is running against test cases, and a redirect to a login screen does not just interrupt the session, it destroys work that cannot be reconstructed. We only get one shot at each run.
So the requirement is not "secure" in the abstract. It is: a credential has to be refreshable, invisibly, with no interaction, while a streaming connection stays open, and a deliberate logout still has to kill access immediately. Those two pull in opposite directions, and nearly every design mistake we made came from optimising one and forgetting the other.
Refresh token rotation and the two numbers that decide everything
Strip away the vocabulary and browser auth comes down to two durations: how long a credential is accepted, and how long you can mint a new one without the user re-authenticating. Everything else is plumbing.
Short access tokens are the only lever that actually works
The IETF OAuth working group's draft BCP for browser-based applications puts it plainly: the recommended defensive strategy against a compromised access token is to reduce its scope and its lifetime. There is no clever storage trick that beats this. A browser app is a public client, the code ships to the user, and anything the page can read, injected script can read too.
Which means the honest question is not "how do we stop the token leaking" but "how much is a leaked token worth". A token that expires in minutes is worth very little. One that expires in a day is worth a day.
The temptation on a long-session product is to stretch the access token to cover the whole interview — one hour, maybe two, so nothing can expire mid-run. That is exactly the wrong move. It converts every leak into an hour of unrestricted access, and it buys you nothing you could not get from a refresh flow. Session length belongs in the refresh layer, not in the access token.
Refresh token rotation, and the ceiling it must never raise
Refresh token rotation is simple to state: every time a refresh token is redeemed, the server issues a new one and invalidates the old. The old one becoming invalid is the whole point — if it is ever presented again, someone has a copy, and that reuse is a detectable signal rather than silent long-term access.
The trap is what rotation does to your expiry ceiling. If each new refresh token gets a fresh full lifetime, you have built an immortal session: a token that is used once an hour never expires, and neither does a stolen one.
The three rules we hold ourselves to
The draft states these as requirements on authorization servers, and they are worth copying verbatim into your own design review:
- Either rotate refresh tokens on each use, or use sender-constrained refresh tokens.
- Either set a maximum lifetime on refresh tokens, or expire them after a period of non-use.
- When issuing a rotated refresh token, do not extend its lifetime beyond that of the initial refresh token.
The third rule is the one people skip. The draft's own worked example makes the shape concrete: an access token lasting ten minutes and a refresh token lasting eight hours, where the refresh after ten minutes returns a new refresh token valid for seven hours and fifty minutes — not a fresh eight. Eight hours after the original authorization, the refresh fails and the user authenticates again. The ceiling is set once, at the start, and rotation slides tokens underneath it without ever lifting it.
The one thing to take away: rotation protects you only if the rotated token inherits the original expiry. A rotated token with a refreshed lifetime is an infinite session with extra steps — it reads as best practice in a code review and behaves like a permanent credential in production.
What rotation does not do
It is worth being clear-eyed here, because vendor documentation tends to oversell this. The same draft states that refresh token rotation is not sufficient to prevent abuse of a refresh token. An attacker running script in the page can simply make sure the real app never uses the newest token — clear the app's tokens after stealing them, or wait until the user closes the tab. No reuse ever happens, so nothing is detected.
Rotation is a damage-bounding mechanism, not a prevention mechanism. It shortens the window and gives you a tripwire. Treat it as one layer, and keep the layer that actually matters: not handing the browser a long-lived credential in the first place.
Where the credential lives: HttpOnly cookies, not JavaScript
Our client holds no tokens. The session is a cookie the browser attaches automatically, and the server keeps the record behind it. On load, the app calls one bootstrap request with credentials included, gets back the current user, and puts that in Redux. That is the entire client-side surface of our auth.
This is the pattern the draft calls a backend-for-frontend: the backend manages tokens in the context of a cookie-based session, so no token is ever exposed to the browser-based application. The draft's assessment of the payoff is specific — HttpOnly cookies prevent an attacker from directly accessing the session state, which is what stops access token theft from escalating into session hijacking.
What the client is allowed to know
Deliberately, almost nothing: who the user is, what their plan allows, and whether they are authenticated. No token, no expiry timestamp, no refresh logic. The client cannot refresh a session because it cannot see one, and that is a feature — there is no client-side token lifecycle to get wrong, and no code path where a bug leaks a credential into a log or an error report.
It also means authorization state is not something the UI owns. Our Redux session slice holds a cached projection of the user, never the credential. A logged-out user with a stale Redux store still gets a 401 from the API, because the server is the only authority on whether the session is alive.
The cookie attributes that do the work
Per the OWASP Session Management Cheat Sheet, a session cookie should carry Secure, HttpOnly, and an explicit SameSite value — never the browser default, which varies across browsers and versions. OWASP also recommends the __Host- name prefix for session IDs, which forces Secure, forbids a Domain attribute and pins Path=/:
Set-Cookie: __Host-SessionID=<value>; Secure; HttpOnly; SameSite=Strict; Path=/
One detail from MDN's Set-Cookie reference catches people out: HttpOnly blocks document.cookie, but an HttpOnly cookie is still sent with JavaScript-initiated requests such as fetch(). Script in your page cannot read the credential, but it can absolutely use it. HttpOnly buys you confidentiality, not authorization — CSRF protection is a separate job, and OWASP is explicit that SameSite is defense in depth rather than a replacement for a CSRF token.
The constraint nobody warns you about: EventSource takes no headers
Here is the detail that settled the argument for us, and it is not a security argument at all.
We stream code-execution results to the browser with server-sent events. If you have read how we stream test results over SSE with Redis, you know the browser side is an EventSource. And per MDN's EventSource documentation, the constructor accepts a URL and, optionally, credentials mode. That is the whole API. There is no header dictionary, no interceptor, no way to attach Authorization: Bearer.
So a bearer-token architecture leaves you three bad options: put the token in the query string, where it lands in access logs and browser history; ship a polyfill that reimplements SSE over fetch just to set one header; or authenticate the stream with cookies. Only the third is boring, and boring is what you want in the component that runs for an hour.
There is a fossil in our SSE hook that makes this concrete. It still reads a token out of localStorage into a variable — and then never uses it, because the connection is opened with withCredentials and the cookie does the work:
// leftover from the first version; the token cannot be attached
const token = localStorage.getItem('token');
eventSourceRef.current = new EventSource(sseUrl, {
withCredentials: true,
});
That dead line is the shortest summary of this whole post. Somebody wrote it expecting to set a header, found out they could not, reached for cookies, and the variable survived the rewrite. The same reasoning applies to the transport choices in our voice interview stack, where connection setup is similarly constrained in what it can carry.
How a refresh lands mid-interview
Because the refresh happens entirely server-side, the client never sees it. The stream does not reconnect, the audio does not drop, and no component re-renders because of auth. The flow looks like this:
Note what crosses the left-hand boundary: a cookie going right, data coming back. No token, in either direction, ever.
Keeping the session alive without making logout decorative
This is where long-session products go wrong. You need a session that does not die during a fifty-minute interview, and the lazy way to get one is a very long expiry that slides forward on every request. Do that and you have built something that effectively never ends.
Two timers, not one
OWASP's framing is the one to adopt, because it splits the problem cleanly:
- Idle timeout. Expires after a period of inactivity, and slides forward on real activity. This is what keeps a live interview alive. OWASP puts common ranges at 2–5 minutes for high-value applications and 15–30 minutes for low-risk ones.
- Absolute timeout. Counts from when the session was created and never moves, regardless of activity. OWASP suggests 4–8 hours for an application someone uses through a working day. This is the ceiling the idle timer cannot push.
- Renewal timeout. Rotates the session identifier periodically mid-session, independently of activity. OWASP recommends this specifically when the absolute timeout is long — which, for us, it is.
An active interview refreshes the idle timer constantly, so it never expires mid-answer. The absolute timeout still fires on schedule. A tab left open overnight is dead by morning no matter how much background chatter it generated. And critically, OWASP is explicit that this must be enforced server-side: if the client tracks the deadline, the client can move it.
Why logout has to be a server-side event
Clearing a cookie is a UI gesture, not a security control. OWASP says it directly — deleting a cookie alone does not invalidate a stolen copy of the session ID. If the server still honours that value, logout did nothing to anyone holding a copy.
So logout invalidates the session record. The cookie clears too, but that is cosmetic; the record being gone is what matters. The draft's guidance for a cookie-based backend lines up: when the backend learns a refresh token for an active session is no longer valid, it should invalidate the session, and the session lifetime should match the maximum refresh token lifetime. Keeping those two in sync means there is one answer to "is this user still in", not two that can disagree.
One more thing we do not do: rely on the browser telling us the user left. OWASP notes that browser close events cannot reliably enforce logout — beforeunload may not fire when a mobile browser is killed from the app manager. Server-side timeouts and an explicit logout action are the controls. Client-side handlers can supplement them, never replace them.
What to copy, and what to skip
If you are building something session-heavy — a long assessment, a live editor, a call — the shape that has held up for us is short:
- Keep tokens out of JavaScript entirely. A server-side session behind an HttpOnly cookie removes a whole category of bug.
- Use refresh token rotation, and make sure the rotated token inherits the original expiry ceiling.
- Let the access token be short. Buy session length with refreshes, never with a longer access token.
- Run an idle timer and an absolute timer, both server-side, and never let the first move the second.
- Make logout destroy state, not just clear a cookie.
And skip the thing that looks most like sophistication: a client-side token manager with a refresh queue, expiry maths and interceptors. It is a lot of code whose only purpose is to handle a credential you should not have given the browser. Check whether a cookie session solves your problem first — if your app opens an EventSource anywhere, the browser has already made the decision for you.
You can see the result of all this by starting a session on Taqari's free AI mock interviews: the auth layer is the part you never notice, which is the only review an auth layer ever wants.
Frequently asked questions
What is refresh token rotation?
+
Refresh token rotation means the authorization server issues a brand-new refresh token every time an old one is redeemed, and invalidates the one just used. If a token is ever presented twice, that reuse is a signal the token was copied, and the server can revoke the whole grant.
How long should an access token last?
+
Short enough that a stolen one expires before it is useful. The IETF browser-apps draft uses ten minutes in its worked example. The exact number matters less than the rule: lengthen the session with a refresh flow, never by extending the access token.
Should tokens be stored in localStorage or an HttpOnly cookie?
+
An HttpOnly cookie, held by a server-side session. JavaScript cannot read an HttpOnly cookie, so a script injection cannot copy the credential out. The IETF draft notes this is what stops access token theft from escalating into full session hijacking.
Can EventSource send an Authorization header?
+
No. The EventSource constructor takes a URL and a credentials-mode flag, and nothing else. There is no header dictionary. A server-sent events stream therefore has to authenticate with cookies, which is a strong architectural reason to keep the session in a cookie.
How do you keep a long session alive without weakening logout?
+
Separate the two timers. An idle timeout slides forward on real activity so a live session never dies mid-answer, while an absolute timeout counts from first login and never moves. Logout then revokes the server-side record rather than merely clearing a cookie.
Does refresh token rotation stop token theft?
+
It detects replay, which is not the same thing. The IETF draft is explicit that rotation is not sufficient on its own: an attacker who can keep the app from ever using the newest token produces no detectable reuse. Rotation bounds the damage, it does not prevent it.
Sources
Keep reading
Scaling Real-Time Apps: Best Practices for WebSocket Infrastructure
WebSockets can be costly for your infrastructure. Here are the core practices I used at Taqari.com to ensure a solid and secure communication layer.
7 min readEngineeringRedux Toolkit Re-Renders: What We Keep Out of State
Redux Toolkit re-renders traced through a live AI interview session: what belongs in the session slice, what stays in a ref, and why the audio path skips React.
9 min readCareer & EdTech NewsThe Tech and Management Job Market in 2026: Where the Opportunities Are
A data-backed look at tech and management hiring in 2026, with the latest figures from India and the U.S. and practical advice for job seekers.
9 min readPreparing for an interview?
Start with these guides, then practise a full round with the AI interviewer.