DEV Community

Devil
Devil

Posted on

The JWT Refresh Race Condition Nobody Talks About (And How I Fixed It)

The Bug That Kept Logging Me Out

I was building a PWA with a custom Node.js backend and Supabase auth.
Everything worked fine — until users (me) kept getting randomly logged out
for no obvious reason.

No errors. No warnings. Just suddenly back at the login screen.

What Was Actually Happening

Most JWT auth setups use two refresh strategies:

Proactive — a timer fires ~1 minute before the access token expires and
refreshes it silently in the background.

Reactive — an Axios interceptor catches 401 errors and refreshes the token
when a request fails.

The problem: both fired at the same time.

Here's the exact sequence:

  1. Proactive timer fires → sends refresh token to backend
  2. An API call returns 401 simultaneously → interceptor also sends the same refresh token
  3. Backend receives two requests with the same refresh token
  4. First one succeeds → Supabase rotates the token, old one is now dead
  5. Second one fails with 401 → interceptor hits the failure path → clears localStorage → redirects to /login

User is logged out. No error. No warning. Just gone.

Why Existing Libraries Don't Fix This

I checked axios-auth-refresh and axios-auth-refresh-queue. Both solve
concurrent 401s — multiple requests failing at the same time.

But neither coordinates with a proactive timer. They only know about
requests going through Axios. Your timer fires outside of that.

The Fix

Both the proactive timer and the reactive interceptor need to share a
single lock. If one is already refreshing, the other should join a queue
and wait for the result — not fire a second request.

I extracted this pattern into a small package:

axios-refresh-sync

npm install axios-refresh-sync
Enter fullscreen mode Exit fullscreen mode
import { createRefreshManager } from 'axios-refresh-sync'

const manager = createRefreshManager({
  axiosInstance: api,
  refreshEndpoint: '/api/auth/refresh',
  getAccessToken: () => localStorage.getItem('access_token'),
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  setTokens: (accessToken, refreshToken) => {
    localStorage.setItem('access_token', accessToken)
    localStorage.setItem('refresh_token', refreshToken)
  },
  onRefreshFailed: () => window.location.href = '/login'
})

// Call after login or app init
manager.scheduleRefresh()
Enter fullscreen mode Exit fullscreen mode

That's it. Both the timer and the interceptor now coordinate under one lock.
If one is mid-refresh, the other waits. Supabase only gets called once.

How It Works Internally

The core is a simple shared lock module:

  • acquireLock() — check if a refresh is already running
  • setLock(true/false) — claim or release the lock
  • enqueue() — join the waiting queue if locked
  • flushQueue() — resolve or reject everyone waiting

Both proactive.js and interceptor.js import from this same lock.
Neither can race the other.

Works With Any Backend

Despite being built with Supabase in mind, the package is completely
backend agnostic. As long as your refresh endpoint accepts a refresh token
and returns new tokens, it works.

Storage is also configurable — you bring your own get/set functions,
so localStorage, cookies, or anything else works.

Links

If you've ever had users mysteriously logged out and couldn't figure out why —
this might be it.

Top comments (0)