My JWT auth flow in React looked "done." Access token stored in memory, refresh token in an httpOnly cookie, auto-retry on 401 — every box checked. Then I wrote one test for what happens when the refresh token itself expires.
It didn't fail. It hung for 5 seconds and timed out.
The Setup
The flow itself is a pretty standard pattern: short-lived access token kept in memory (React state via Context), long-lived refresh token stored as an httpOnly cookie so client-side JS can never touch it — even if there's an XSS hole somewhere.
On top of that, an axios interceptor does two things automatically:
- Attaches the access token to every outgoing request
- Catches 401s, silently calls the refresh endpoint, retries the failed request with the new token
I'd already fixed one race condition — two requests failing with 401 at nearly the same time used to trigger two separate refresh calls, with the second one just rejecting instead of waiting. Fixed that with a subscriber queue: any request that hits a refresh already in progress gets parked as a pending Promise, and gets resolved (or rejected) once the first refresh finishes.
Manually, everything checked out. Login worked. Refresh worked. Concurrent requests during a refresh worked. Confident it was solid, I wrote a test for the one case I hadn't manually tried: the refresh token itself being expired.
The Bug Before This One
Before I got to the infinite hang, I'd already fixed a different bug: two requests failing with 401 at nearly the same time triggered two separate refresh calls. The second request didn't wait for the first refresh to finish — it just rejected immediately, even though the refresh already in flight was about to succeed.
The fix at the time: a subscriber queue. A request that found a refresh already in progress wouldn't reject right away — it would park itself as a pending Promise, get added to a queue, and get resolved (or rejected) once the first refresh actually finished.
That same pattern is what caused the infinite hang next — because the "just queue it" logic didn't only apply to regular requests. It unintentionally applied to the refresh request itself when it failed.
The Investigation
The test itself was simple: mock the refresh endpoint to return 401 (simulating an expired refresh token), fire two requests at once that both hit 401, and check that both eventually reject cleanly.
The test didn't fail with a clear error. It timed out.
Error: Test timed out in 5000ms.
A timeout is a different kind of signal than a normal failing assertion — it's not that something was wrong, it's that something never finished. That's what pointed me toward an infinite hang rather than a logic bug.
The cause turned out to be this line:
// The refresh call goes through the SAME instance as its own interceptor
const res = await apiClient.post('/api/auth/token/refresh/');
apiClient is the same axios instance that carries the retry/refresh interceptor described above. When this refresh request itself failed with 401, axios automatically re-invoked that same interceptor — this time for the refresh request.
At that point, isRefreshing was still true — the first refresh attempt hadn't finished, still sitting mid-await. So the refresh request itself got caught by the "a refresh is already in progress, queue this instead" branch — its interceptor returned a Promise that hadn't been resolved with anything yet.
Here's the problem: that pending Promise became the result of the await that request A was sitting on. And the only thing that could ever resolve it was A's own catch block — which would never run, because A was still stuck waiting for that Promise to settle first.
A was waiting for the refresh to finish. The refresh — which had actually already failed — was waiting for A to say so. A circular wait with no way out.
The fix: send the refresh request through an axios instance that carries no interceptor at all.
// A separate instance, dedicated to refreshing — no interceptors attached
const refreshClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
});
// ...
const res = await refreshClient.post('/api/auth/token/refresh/');
Now the refresh request never re-enters its own retry interceptor, no matter what status code comes back. If it fails, it fails normally — no recursion, no queue that never resolves.
The test that used to time out now passes in milliseconds.
What I Took Away From This
Three things stuck with me. First, this kind of bug doesn't show up in manual testing because the refresh token only expires after 7 days — not something you're testing every day during development. Second, out of the three tests I wrote, only one caught the bug — and it was the one aimed specifically at the refresh-failure scenario, not the easiest one to write. Third, the fix for the first bug (the subscriber queue) turned out to be the root cause of the second one.
Repo
- Backend (Django/DRF): github.com/Iqbal120708/url-shortener
- Frontend (React/TypeScript): github.com/Iqbal120708/url-shortener-web
Ever run into a similar deadlock in your own auth flow? Curious to hear how you caught it — or if it's still lurking somewhere.
Top comments (0)