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
Authorizationheader (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
- An API call is made with an expired AT
- The server returns a 401
- The Axios response interceptor catches the 401
- It calls
/auth/refreshwith the RT to get a new AT - 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.
| Approach | Pros | Cons |
|---|---|---|
| Web Locks API | Origin-scoped lock guarantees only one tab refreshes | Not supported in legacy browsers |
| BroadcastChannel API | Can notify other tabs about token refreshes | Doesn't provide locking; requires separate coordination logic |
| localStorage events | Widest browser compatibility | storage 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 Session | Token-based Equivalent |
|---|---|
| Reset cookie maxAge | Issue new RT on use + reset expiration |
| Renew server session TTL | Extend RT expiration on each activity |
lastActivity timestamp | Store 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.
| Appropriate | Not Appropriate |
|---|---|
| Social media, content consumption apps | Financial transactions, banking apps |
| Internal business tools (used all day) | Healthcare information systems |
| E-commerce browsing | Admin 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.
- On initial authentication, RT-1 is issued. This is the family root.
- When RT-1 is used, RT-2 is issued, and RT-1 is invalidated.
- 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.
- The client requests a refresh with RT-1
- The server issues RT-2 and invalidates RT-1
- The response never reaches the client due to a network failure
- The client still only has RT-1
- 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
| Pattern | Behavior | Pros | Cons |
|---|---|---|---|
| Silent Refresh | Detect 401 -> refresh AT with RT -> retry | Intuitive implementation, standard in most frameworks | One initial 401 failure cost |
| Proactive Refresh | Preemptive refresh before expiration | Seamless experience with no 401s | Requires JWT decoding logic, clock sync issues |
| Sliding Session | Extend session on each activity | UX similar to traditional web sessions | Weaker security (possibility of permanent sessions) |
| RT Rotation | Issue new RT on each use, revoke previous | Recommended by OAuth 2.0 BCP, enables theft detection | Risk 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.