CORE JSC

International Technology Partnership

Web Development & SEO

Fixing Silent Auth Token Expiry That Logs Users Out Mid-Action Instead of Refreshing Quietly

A user is midway through filling out a long form or completing a checkout, an access token quietly expires in the background, and the next API call returns 401 — which the app interprets as "log the user out and redirect to login," discarding everything they were doing. The token refresh mechanism exists in the codebase; it just isn't the thing actually running when this happens.

Core JSC Team·September 25, 2026
Web DevelopmentAuthenticationJWTToken RefreshAPI

The Problem

An app uses short-lived access tokens with a refresh token to silently obtain a new one when the access token expires — a standard, well-understood pattern. In practice, users occasionally get bounced to the login screen in the middle of an action: filling out a form, midway through a multi-step checkout, or simply idle on a page for longer than the access token's lifetime. Whatever they were doing is lost. The refresh logic exists in the codebase and often works correctly in isolated testing — the bug is in exactly when and how it's actually invoked relative to the request that triggers a 401.

Why It Happens

Not every API call is routed through the code path that knows how to refresh

If refresh logic is implemented as a wrapper around one particular HTTP client instance, but some part of the app makes requests directly via fetch, a different client instance, or a third-party SDK's own networking layer, those calls bypass the refresh mechanism entirely. A 401 from one of those unwrapped calls goes straight to whatever global error handling exists — which is often "treat any 401 as a real auth failure and log out."

Multiple concurrent requests hitting 401 at the same time can trigger multiple simultaneous refresh attempts, and only some of them wait correctly

When several requests are in flight and the access token expires, each one can independently receive a 401 and independently attempt to trigger a refresh. Without coordination, this can produce redundant refresh calls (wasting a limited-use refresh token faster than expected) or a race where one request's failure handler fires before the refresh actually completes, incorrectly treating a recoverable, in-progress refresh as a hard failure.

The refresh token itself can expire or be invalidated, and that genuinely different failure gets treated identically to a routine access-token expiry

A 401 caused by an actually-expired refresh token (the user hasn't used the app in weeks) is a real case where logging out is correct. But if the code doesn't distinguish this from a routine access-token expiry mid-session, both paths end up looking identical from the app's perspective, making it hard to reason about — and easy to accidentally treat a recoverable case as unrecoverable.

The logout handler runs before any in-flight refresh attempt has a chance to resolve

If a global 401 interceptor immediately clears auth state and redirects on the first 401 it sees, it can fire before a refresh that was already in progress (triggered by an earlier, near-simultaneous request) has had a chance to complete and retry the original request — effectively racing its own recovery mechanism and losing.

The Fix

1. Route every authenticated request through a single interceptor layer that owns the refresh logic

// axios example: one instance, one interceptor, used everywhere
const api = axios.create({ baseURL: "/api" });

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401 && !error.config._retried) {
      error.config._retried = true;
      await refreshAccessToken();
      return api(error.config); // retry the original request with the new token
    }
    return Promise.reject(error);
  }
);

Ensuring every authenticated request — including ones made by third-party SDKs, where possible, by configuring them to use the same client instance — goes through one interceptor that owns 401 handling removes the class of bug where some requests simply never had a chance to trigger a refresh in the first place.

2. Deduplicate concurrent refresh attempts with a shared in-flight promise

let refreshPromise = null;

async function refreshAccessToken() {
  if (refreshPromise) return refreshPromise; // reuse the already-in-progress refresh
  refreshPromise = performTokenRefresh().finally(() => {
    refreshPromise = null;
  });
  return refreshPromise;
}

Caching the in-flight refresh promise and having every concurrent 401 wait on the same one, rather than triggering a new refresh call each, prevents both wasted refresh-token usage and the specific race where one request's failure branch fires before a refresh that was actually already succeeding.

3. Distinguish an expired refresh token from a routine access-token expiry explicitly

async function performTokenRefresh() {
  try {
    const { accessToken } = await api.post("/auth/refresh", {
      refreshToken: getStoredRefreshToken(),
    });
    setAccessToken(accessToken);
    return accessToken;
  } catch (refreshError) {
    // Only a failure of the refresh call itself means the session is genuinely over
    logout();
    throw refreshError;
  }
}

Treating a failed refresh-token exchange, specifically, as the actual "session over" signal — rather than any 401 encountered anywhere in the app — means a routine access-token expiry gets silently recovered from, while a genuinely dead session still correctly results in a logout, without conflating the two.

4. Queue and retry requests that were in flight when the refresh started, rather than failing them immediately

let pendingRequests = [];

function onRefreshed(newToken) {
  pendingRequests.forEach((cb) => cb(newToken));
  pendingRequests = [];
}

// In the interceptor: if a refresh is already in progress, queue this
// request's retry instead of failing it outright
new Promise((resolve) => {
  pendingRequests.push((token) => {
    error.config.headers.Authorization = `Bearer ${token}`;
    resolve(api(error.config));
  });
});

Queuing a request that arrived mid-refresh, rather than immediately failing it, ensures the original user action — the form submission, the checkout step — actually completes once the new token is available, instead of surfacing as a lost action even when the refresh itself worked correctly.

Why This Works

Each fix closes a different gap between how token refresh is supposed to work and the specific conditions under which it silently doesn't. Routing every request through one interceptor ensures nothing bypasses the refresh mechanism; deduplicating concurrent refresh attempts prevents both wasted calls and races against the app's own recovery; explicitly distinguishing a dead refresh token from a routine expiry keeps a recoverable case from being treated as a hard failure; and queuing in-flight requests during a refresh ensures the user's original action actually completes rather than being silently dropped.

Conclusion

Users getting logged out mid-action isn't usually a missing refresh mechanism — it's the refresh logic not actually running for the specific request that hit a 401, due to an unwrapped client, an uncoordinated concurrent refresh, or a global handler that fires before recovery has a chance to complete. Route every authenticated call through a single interceptor that owns 401 handling, deduplicate concurrent refresh attempts behind a shared promise, treat only a failed refresh-token exchange as a genuine session end, and queue requests that arrive mid-refresh so the user's original action completes instead of being silently lost.