Comparing Frontend Auth Refresh Patterns: From Silent Refresh to RT Rotation

KO

Introduction

In a project I was working on, a user was writing a post and hit "Save" only to get bounced to the login page. The AT had expired, and this wouldn't have happened if the token refresh logic had been properly set up. That was the nudge I needed to compare four patterns for handling AT renewal, along with their implementations and combination strategies.

The Basics: AT/RT Architecture

  • Access Token (AT): A short-lived token carried in the Authorization header (typically 15 min to 1 hour)
  • Refresh Token (RT): A long-lived token used to obtain a new AT when it expires (typically 7 to 30 days)

When the AT expires, the RT is used to get a new AT. When the RT also expires, the user must re-authenticate. The difference between refresh patterns comes down to "when and how do you detect and handle AT expiration?"

1. Silent Refresh

The most intuitive pattern. When an API request returns a 401, the response interceptor uses the RT to refresh the AT and retries the original request. The user never even knows a failure occurred.

How It Works

  1. An API call is made with an expired AT
  2. The server returns a 401
  3. The Axios response interceptor catches the 401
  4. It calls /auth/refresh with the RT to get a new AT
  5. The failed request is retried with the new AT

Handling Concurrent Requests Is the Key

When a dashboard loads, multiple APIs fire simultaneously for user info, notifications, stats, etc. If the AT has expired, all of these requests will receive a 401. If each one independently attempts a refresh, you've got a problem. Beyond server load, if you're using RT Rotation, all refresh attempts after the first will be using an already-revoked RT and will fail.

The first 401 triggers the refresh, and the rest queue up and wait.

interface QueueItem {
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}
 
let isRefreshing = false;
let failedQueue: QueueItem[] = [];
 
const processQueue = (error: unknown, token: string | null) => {
  failedQueue.forEach((item) => {
    if (token) {
      item.resolve(token);
    } else {
      item.reject(error);
    }
  });
  failedQueue = [];
};
 
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
 
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise<string>((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers = {
            ...originalRequest.headers,
            Authorization: `Bearer ${token}`,
          };
          return api(originalRequest);
        });
      }
 
      originalRequest._retry = true;
      isRefreshing = true;
 
      try {
        const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
          refreshToken: getRefreshToken(),
        });
 
        setAccessToken(data.accessToken);
        processQueue(null, data.accessToken);
 
        originalRequest.headers = {
          ...originalRequest.headers,
          Authorization: `Bearer ${data.accessToken}`,
        };
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
 
    return Promise.reject(error);
  },
);

The isRefreshing flag tracks whether a refresh is in progress, and requests queued in failedQueue are retried in bulk once the refresh completes.

The Multi-Tab Problem

Concurrent requests within the same tab are solved by the queue, but across tabs the JS execution contexts are isolated, so the isRefreshing flag isn't shared. With 3 tabs open, 3 independent refreshes can fire simultaneously. In an RT Rotation environment, the moment the first refresh completes, the RTs held by the other tabs become invalid, causing the entire token family to be revoked.

There are three solutions.

ApproachProsCons
Web Locks APIOrigin-scoped lock guarantees only one tab refreshesNot supported in legacy browsers
BroadcastChannel APICan notify other tabs about token refreshesDoesn't provide locking; requires separate coordination logic
localStorage eventsWidest browser compatibilitystorage events don't fire in the same tab

In practice, using Web Locks to guarantee single execution and BroadcastChannel to propagate the new token to other tabs has been the most robust combination.

async function refreshWithLock(): Promise<string> {
  return navigator.locks.request('token-refresh', async () => {
    const currentToken = getAccessToken();
    if (currentToken && !isTokenExpired(currentToken)) {
      return currentToken;
    }
 
    const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
      refreshToken: getRefreshToken(),
    });
 
    setAccessToken(data.accessToken);
 
    const channel = new BroadcastChannel('auth');
    channel.postMessage({ type: 'TOKEN_REFRESHED', token: data.accessToken });
    channel.close();
 
    return data.accessToken;
  });
}

2. Proactive Refresh

Silent Refresh has an inherent limitation: it has to fail at least once. Most of the time the user won't notice, but occasionally the UI can briefly flash an error state between the failure and retry.

Proactive Refresh flips this around by decoding the AT's exp claim and refreshing preemptively before expiration. It greatly reduces the chance of a 401, but it does not eliminate edge cases such as clock skew or server-side invalidation.

Implementation: Request-time Check vs Timer-based

Request-time check inspects exp right before every API call. It's efficient because no unnecessary refreshes happen when the user is inactive.

function getTokenExpiry(token: string): number {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  const payload: { exp: number } = JSON.parse(atob(base64));
  return payload.exp * 1000;
}
 
const REFRESH_BUFFER_MS = 60_000; // 60 seconds before expiration
 
api.interceptors.request.use(async (config) => {
  const accessToken = getAccessToken();
 
  if (accessToken) {
    const expiry = getTokenExpiry(accessToken);
    const now = Date.now();
 
    if (expiry - now < REFRESH_BUFFER_MS) {
      const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      config.headers.Authorization = `Bearer ${data.accessToken}`;
    } else {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
  }
 
  return config;
});

Timer-based schedules a refresh via setTimeout when the token is issued. It keeps the token valid at all times, regardless of whether API calls are being made.

function scheduleTokenRefresh(accessToken: string) {
  const expiry = getTokenExpiry(accessToken);
  const now = Date.now();
  const delay = Math.max(expiry - now - REFRESH_BUFFER_MS, 0);
 
  setTimeout(async () => {
    try {
      const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      scheduleTokenRefresh(data.accessToken);
    } catch {
      logout();
    }
  }, delay);
}

However, the timer-based approach has a caveat. Chrome-family browsers can throttle setTimeout aggressively in background tabs depending on the conditions. In common cases the check interval slows to about once per second, and under longer-lived background conditions it can become minute-level throttling. Timers may not fire on time when the tab is inactive, so you need to supplement it with the visibilitychange event.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    const token = getAccessToken();
    if (token && isTokenExpired(token)) {
      refreshTokenImmediately();
    }
  }
});

The Clock Skew Problem

exp is a Unix timestamp based on the server's clock. If the client's clock is 2 minutes behind the server, the client will think the token is still valid while the server has already rejected it.

You could calculate the offset by comparing the server response's Date header with Date.now(), but in practice, setting a generous refresh buffer is sufficient. 10-20% of the AT's lifetime (minimum 30 seconds, maximum 5 minutes) covers most clock skew scenarios.

As a side note, decoding the JWT payload on the client isn't a security concern. JWTs are signed, not encrypted. That said, the client's exp check is merely a UX optimization hint, and the server must always perform its own signature verification and exp validation.

3. Sliding Session

A pattern that pushes back the session expiration each time the user is active. It replicates the same UX as resetting cookie maxAge in traditional server sessions, but in a token-based auth system.

Implementation in Token-based Auth

The most common approach is to apply sliding expiration to the RT. The AT keeps a fixed expiration (15 minutes), while each RT usage issues a new RT with a reset expiration time.

Traditional SessionToken-based Equivalent
Reset cookie maxAgeIssue new RT on use + reset expiration
Renew server session TTLExtend RT expiration on each activity
lastActivity timestampStore lastUsedAt in server DB

Security Weakness: The Possibility of Infinite Sessions

The biggest issue with Sliding Session is that sessions can theoretically be extended indefinitely. If an attacker steals a token, they can maintain a forever-valid session by simply refreshing periodically. A single line of automation script is all it takes.

OWASP explicitly warns about this and recommends always using it in conjunction with an absolute timeout. This is a hard ceiling that forces re-authentication after a certain period, no matter how active the user has been.

  • High-risk (finance, healthcare): idle timeout 2-5 minutes, absolute timeout a few hours
  • Low-risk (content, social media): idle timeout 15-30 minutes, absolute timeout 4-8 hours
interface RefreshTokenPayload {
  userId: string;
  family: string;
  createdAt: number; // basis for absolute timeout
  lastUsedAt: number; // basis for idle timeout
}
 
function validateRefreshToken(payload: RefreshTokenPayload): boolean {
  const now = Date.now();
  const IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
  const ABSOLUTE_TIMEOUT = 8 * 60 * 60 * 1000; // 8 hours
 
  if (now - payload.lastUsedAt > IDLE_TIMEOUT) return false;
  if (now - payload.createdAt > ABSOLUTE_TIMEOUT) return false;
 
  return true;
}

It's important to distinguish when Sliding Session is appropriate and when it's not.

AppropriateNot Appropriate
Social media, content consumption appsFinancial transactions, banking apps
Internal business tools (used all day)Healthcare information systems
E-commerce browsingAdmin panels

4. RT Rotation

All the patterns so far share a common vulnerability: if the RT is stolen, the attacker can keep issuing new ATs at will. While the RT is valid, the server has no way to distinguish between a legitimate user and an attacker.

RT Rotation issues a new RT with every refresh and immediately revokes the previous one. The OAuth 2.0 BCP (RFC 9700) mandates this approach for public clients like browsers.

Detecting Theft with Token Families

The real power of RT Rotation isn't just the rotation itself but theft detection.

A user's RT chain is managed as a single token family.

  1. On initial authentication, RT-1 is issued. This is the family root.
  2. When RT-1 is used, RT-2 is issued, and RT-1 is invalidated.
  3. When RT-2 is used, RT-3 is issued... (and so on)

The critical moment is when an already-invalidated RT-1 is used again. A legitimate user already has RT-2, so there's no reason to use RT-1. If RT-1 is reused, it means someone had stolen it, and the server immediately revokes all tokens in that family. The legitimate user gets logged out too, but that's safer than allowing continued access from a compromised token.

async function handleRefresh(oldRT: string): Promise<TokenPair> {
  const record = await db.refreshTokens.findOne({
    token: oldRT,
  });
 
  if (!record) {
    throw new UnauthorizedError('Invalid refresh token');
  }
 
  if (record.used) {
    await db.refreshTokens.updateMany({ family: record.family }, { revoked: true });
    throw new UnauthorizedError('Token reuse detected');
  }
 
  await db.refreshTokens.updateOne({ token: oldRT }, { used: true });
 
  const newAT = generateAccessToken(record.userId);
  const newRT = generateRefreshToken({
    userId: record.userId,
    family: record.family,
  });
 
  return { accessToken: newAT, refreshToken: newRT };
}

The RT Loss Problem on Network Failures

The biggest practical issue with RT Rotation is network failure.

  1. The client requests a refresh with RT-1
  2. The server issues RT-2 and invalidates RT-1
  3. The response never reaches the client due to a network failure
  4. The client still only has RT-1
  5. It tries RT-1 again -> reuse detected -> entire family revoked -> forced logout

The user gets logged out through no fault of their own. This is why services like Auth0 and Okta provide a Grace Period. If an invalidated RT is reused within a short window (typically 30 seconds), it's not treated as reuse and a new token is issued instead.

Since theft detection is disabled during the Grace Period, keeping it as short as possible is important.

Refresh Pattern Comparison

PatternBehaviorProsCons
Silent RefreshDetect 401 -> refresh AT with RT -> retryIntuitive implementation, standard in most frameworksOne initial 401 failure cost
Proactive RefreshPreemptive refresh before expirationSeamless experience with no 401sRequires JWT decoding logic, clock sync issues
Sliding SessionExtend session on each activityUX similar to traditional web sessionsWeaker security (possibility of permanent sessions)
RT RotationIssue new RT on each use, revoke previousRecommended by OAuth 2.0 BCP, enables theft detectionRisk of RT loss on network failures

These four patterns are not mutually exclusive. In practice, they're used in combination.

Combination Strategies for Production

Silent Refresh + RT Rotation

The most common production setup. When a 401 occurs, the interceptor triggers a refresh, and the server rotates the RT on every refresh. It's simple to implement while still enabling theft detection.

However, you must prevent concurrent refreshes in multi-tab environments using the Web Locks API.

Proactive Refresh + Silent Refresh (Fallback)

The most robust combination.

  • First line of defense (Proactive): The request interceptor checks exp, and if expiration is imminent, refreshes preemptively
  • Second line of defense (Silent): If proactive refresh fails due to clock differences or network latency and a 401 occurs, the response interceptor catches it

99% of the time, proactive refresh handles it and the user never experiences a 401. For the remaining 1%, silent refresh acts as a safety net.

Add RT Rotation on top and you get the Proactive + Silent + RT Rotation triple combination.

Anti-patterns to Avoid

1. RT Rotation + Sliding Session without an absolute timeout

If every RT usage resets the new RT's expiration, an attacker can infinitely refresh a stolen token. You must always set an absolute timeout alongside it.

2. Using RT Rotation across multiple tabs without synchronization

If each tab independently attempts refreshes, the token family gets wiped out all at once, causing the user to be suddenly logged out.

3. Infinite retries on refresh failure

If the RT has expired or been revoked, retries are pointless and only add load to the server. On failure, redirect to the re-authentication flow immediately.

4. Using timer-based proactive refresh as the sole strategy

Browsers can delay background tab timers, and devices can enter sleep mode, causing timers not to fire. You must always have 401-based silent refresh as a fallback.

Decision Framework

The criteria I currently use are straightforward.

  • General web services: Proactive Refresh + Silent Refresh (fallback) + RT Rotation
  • Security-sensitive services: the above combination + short AT lifetime (5 min) + mandatory absolute timeout
  • Internal tools/admin panels: Silent Refresh + Sliding Session + absolute timeout

Regardless of which combination you choose, multi-tab synchronization and a re-authentication flow on refresh failure are non-negotiable. Skip either of these, and you'll keep running into users getting unexpectedly logged out in production.