DEV Community

graciesharma
graciesharma

Posted on

You're Probably Refreshing Auth Tokens Wrong. Here's a 40-Line Fix.

TL;DR: When multiple API calls fire at once and the token is expired, most apps trigger 5-10 duplicate refresh requests simultaneously. This is called a token refresh stampede. It causes race conditions, 401 loops, and random logouts. The fix is 40 lines of TypeScript — a shared promise reference, JWT expiry parsing without libraries, and a 60-second early expiration buffer.

The Bug No One Talks About

Your app loads a dashboard. Five components mount. Five API calls fire. The access token just expired.

What happens next?

POST /auth/refresh → 200 ✅ (new token)
POST /auth/refresh → 200 ✅ (new token... again)
POST /auth/refresh → 200 ✅ (third time)
POST /auth/refresh → 401 ❌ (refresh token already rotated)
POST /auth/refresh → 401 ❌ (same)
Enter fullscreen mode Exit fullscreen mode

Three of those succeed because the server hasn't rotated the refresh token yet. The last two fail because it has. Now two components get 401s. Your interceptor sees 401 and tries to refresh again. You're in a loop. The user gets logged out.

This is a token refresh stampede. And almost every auth tutorial on the internet creates this exact bug.


Why Most Solutions Are Broken

Here's what a "typical" token refresh looks like:

// ❌ The version every tutorial teaches you
async function getAccessToken(): Promise<string | null> {
  const token = localStorage.getItem("access_token");

  if (token && !isExpired(token)) {
    return token;
  }

  // Every concurrent caller hits this line simultaneously
  const response = await fetch("/auth/refresh");
  const { newToken } = await response.json();
  localStorage.setItem("access_token", newToken);
  return newToken;
}
Enter fullscreen mode Exit fullscreen mode

Five components call getAccessToken() at the same time. All five see the expired token. All five call /auth/refresh. Race condition achieved.

Common "fixes" that still break:

Approach Why it fails
Boolean flag (isRefreshing = true) Concurrent callers skip refresh entirely and return null — now they all fail
setTimeout retry Adds latency, still races if timing is unlucky
Queue with callbacks 30+ lines of complexity for something that should be simple
Axios interceptor with retry Re-queues the original request but can still fire multiple refreshes

The Fix: Promise Deduplication in 40 Lines

Here's the actual production code. Read it — it's short.

"use client";

let cachedToken: string | null = null;
let tokenExpiresAt = 0;
let tokenPromise: Promise<string | null> | null = null;

async function fetchAccessToken(): Promise<string | null> {
  try {
    const response = await fetch("/auth/access-token");
    if (!response.ok) return null;
    const { token } = (await response.json()) as { token?: string };
    return token ?? null;
  } catch {
    return null;
  }
}

function parseTokenExpiry(token: string): number {
  try {
    const parts = token.split(".");
    const encodedPayload = parts[1];
    if (!encodedPayload) return Date.now() + 5 * 60 * 1000;

    const payload = JSON.parse(atob(encodedPayload)) as { exp?: number };
    return payload.exp
      ? payload.exp * 1000 - 60_000  // Expire 60 seconds early
      : Date.now() + 5 * 60 * 1000;
  } catch {
    return Date.now() + 5 * 60 * 1000;
  }
}

export async function getAccessToken(): Promise<string | null> {
  // 1. Return cached token if still valid
  if (cachedToken && Date.now() < tokenExpiresAt) {
    return cachedToken;
  }

  // 2. If a refresh is already in-flight, piggyback on it
  if (!tokenPromise) {
    tokenPromise = fetchAccessToken()
      .then((token) => {
        cachedToken = token;
        tokenExpiresAt = token ? parseTokenExpiry(token) : 0;
        return token;
      })
      .finally(() => {
        tokenPromise = null;
      });
  }

  return tokenPromise;
}

export function clearTokenCache() {
  cachedToken = null;
  tokenExpiresAt = 0;
  tokenPromise = null;
}
Enter fullscreen mode Exit fullscreen mode

That's it. No libraries. No queues. No retry loops.


How It Works — 3 Techniques in 40 Lines

Technique 1: Promise Deduplication via Shared Reference

This is the core insight. Instead of a boolean flag, we store the promise itself.

let tokenPromise: Promise<string | null> | null = null;
Enter fullscreen mode Exit fullscreen mode

When the first caller triggers a refresh, the promise is stored. Every subsequent caller gets the same promise. They all await the same network request. One fetch, N consumers.

if (!tokenPromise) {
  // First caller — creates the promise
  tokenPromise = fetchAccessToken().then(/* ... */).finally(() => {
    tokenPromise = null; // Reset for next cycle
  });
}

// All callers — same promise, same result
return tokenPromise;
Enter fullscreen mode Exit fullscreen mode

The .finally() resets the reference after the fetch completes (success or failure), so the next batch of expired calls will trigger a fresh refresh.

Why this beats a boolean flag:

// ❌ Boolean flag — concurrent callers get nothing
if (isRefreshing) return null; // Caller 2, 3, 4, 5 all get null

// ✅ Promise ref — concurrent callers get the same result
if (tokenPromise) return tokenPromise; // Caller 2, 3, 4, 5 all get the token
Enter fullscreen mode Exit fullscreen mode

Technique 2: JWT Expiry Parsing Without Libraries

Most apps use jwt-decode (7KB) or jsonwebtoken (200KB+ with deps) to read token expiry. You don't need them.

A JWT is three base64 segments separated by dots. The middle one is the payload. That's it.

function parseTokenExpiry(token: string): number {
  try {
    const payload = JSON.parse(atob(token.split(".")[1]));
    return payload.exp
      ? payload.exp * 1000 - 60_000
      : Date.now() + 5 * 60 * 1000;
  } catch {
    return Date.now() + 5 * 60 * 1000; // Fallback: 5 min TTL
  }
}
Enter fullscreen mode Exit fullscreen mode

Three lines to parse. Zero dependencies. The try/catch handles malformed tokens gracefully — if parsing fails, default to a 5-minute TTL so the app doesn't break.

Note: This is safe because we're only reading the exp claim client-side. We're not validating the signature — that's the server's job.

Technique 3: 60-Second Early Expiration Buffer

This is the one even senior devs miss.

payload.exp * 1000 - 60_000  // Expire 60 seconds before actual expiry
Enter fullscreen mode Exit fullscreen mode

Why? Network latency. If the token expires at 12:00:00 and your request reaches the server at 12:00:00.300, it gets rejected even though it was valid when you sent it.

By treating the token as expired 60 seconds early, you guarantee the refresh happens before any request hits the server with a stale token. No more intermittent 401s on slow connections.


Wiring It Into Your API Client

import { getAccessToken, clearTokenCache } from "./token-cache";

// Request interceptor — inject token into every request
apiClient.addRequestInterceptor(async (config) => {
  const token = await getAccessToken();
  if (token) {
    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${token}`,
    };
  }
  return config;
});

// Error interceptor — clear cache on 401 so next request triggers refresh
apiClient.addErrorInterceptor(async (error) => {
  if (error.status === 401) {
    clearTokenCache();
  }
  throw error;
});
Enter fullscreen mode Exit fullscreen mode

Before and After

Before (stampede):

Component A → getToken() → fetch /refresh → new token A
Component B → getToken() → fetch /refresh → new token B
Component C → getToken() → fetch /refresh → 401 ❌ (refresh token rotated)
Component D → getToken() → fetch /refresh → 401 ❌
Component E → getToken() → fetch /refresh → 401 ❌
→ User logged out
Enter fullscreen mode Exit fullscreen mode

After (deduplication):

Component A → getToken() → fetch /refresh → new token
Component B → getToken() → awaits same promise ↑
Component C → getToken() → awaits same promise ↑
Component D → getToken() → awaits same promise ↑
Component E → getToken() → awaits same promise ↑
→ 1 network request, 5 components served
Enter fullscreen mode Exit fullscreen mode

Edge Cases Handled

Scenario Behavior
Token valid Returns cached token instantly, no network request
Token expired, single caller Fetches new token, caches it
Token expired, 5 concurrent callers One fetch, all 5 get the same token
Refresh fails Returns null, resets promise so next call retries
Malformed JWT Falls back to 5-minute TTL
401 from server clearTokenCache() forces next request to refresh
Network timeout catch returns null, .finally() resets promise
Token has no exp claim Falls back to 5-minute TTL

Common Questions

"Why module-level variables instead of a class or React state?"

Auth tokens are app-global. Module-level let gives you a singleton automatically — no React context, no provider, no class instantiation. Every import gets the same reference. Simple.

"Why not use navigator.locks?"

Browser support is decent but not universal. The promise reference pattern works everywhere, is simpler, and doesn't need async coordination APIs.

"Does this work with refresh token rotation?"

Yes. That's the entire point. When the server rotates the refresh token on use, the stampede causes all but the first request to fail. This fix ensures only one request is ever made.

"What about SSR?"

The module-level variables are client-only ("use client" directive). On the server, auth is handled via HTTP-only cookies — no client-side token management needed.


The Takeaway

Stop building auth refresh with boolean flags and retry loops.

The fix is one shared promise reference. Every concurrent caller awaits the same in-flight request. Zero stampede. Zero race conditions. Zero dependencies. 40 lines.

Copy the code. Ship it. Move on to the features that actually matter.


Top comments (0)