Refresh Token on the Web: Why, How, and Production Patterns
March 25, 2026
Most auth bugs in production are not about login. They are about session continuity:
- random logout after a few minutes,
- multiple tabs invalidating each other,
- infinite refresh loops,
- refresh token replay attacks.
This post explains refresh token from first principles and then moves into practical implementation details you can ship safely.
1. Why Do We Need Refresh Token?
In modern web apps, access tokens should be short-lived for security reasons. If an attacker steals one, the blast radius should be limited.
But if access tokens expire quickly, users would get logged out frequently. So we split responsibility:
- Access token: short TTL, used for API authorization.
- Refresh token: longer TTL, used only to mint a new access token.
Client State
Access token expires quickly.
Refresh token lives longer.
Access Token TTL
8s
Refresh Action
Real-world problem this solves
- Keep users signed in without making access tokens long-lived.
- Limit damage if access token leaks.
- Allow centralized revocation by invalidating refresh sessions server-side.
What TTL means in practice
TTL means Time to Live: the duration a token remains valid after issuance.
Practical defaults:
- Access token TTL should be short (commonly 5-15 minutes).
- Refresh token TTL can be longer (days or weeks) but must support revocation and rotation.
- TTL should balance UX and business risk, not maximize one side only.
Best practice: refresh 10-30 seconds before expiry
In production, do not wait until access token is fully expired.
Use proactive refresh around 10-30s before exp to reduce:
- in-flight requests failing with
401, - client/server clock skew issues,
- network latency causing requests to arrive after token expiry.
Common formula:
refreshAt = exp - skewBuffer - jitter
skewBuffer: usually 10-30 seconds,jitter: random 0-5 seconds to avoid synchronized refresh storms.
Example React scheduler:
import { useEffect, useRef } from "react";
type SchedulerParams = {
accessTokenExp: number; // epoch seconds
refresh: () => Promise<void>;
onRefreshError: () => void;
};
export function useProactiveRefresh({
accessTokenExp,
refresh,
onRefreshError,
}: SchedulerParams) {
const timerRef = useRef<number | null>(null);
useEffect(() => {
const skewBufferMs = 20_000; // usually 10-30s
const jitterMs = Math.floor(Math.random() * 5_000);
const expMs = accessTokenExp * 1000;
const refreshAt = expMs - skewBufferMs - jitterMs;
const delay = Math.max(refreshAt - Date.now(), 0);
timerRef.current = window.setTimeout(async () => {
try {
await refresh();
} catch {
onRefreshError();
}
}, delay);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [accessTokenExp, refresh, onRefreshError]);
}Note: proactive refresh should complement, not replace, reactive 401 refresh fallback.
2. Core Flow: Login -> Access Expired -> Refresh -> Retry
A healthy refresh pipeline typically looks like this:
- User logs in.
- Server issues access token + refresh token.
- Client calls protected APIs with access token.
- API returns
401when access token expires. - Client calls
/auth/refreshusing refresh token. - Server validates refresh session and returns new access token.
- Client retries the original failed request.
Key implementation rule
Refresh endpoint should be very narrow in scope:
- only token renewal logic,
- strong validation,
- strict rate limiting,
- audit logging.
3. Refresh Token Rotation (Highly Recommended)
Without rotation, a stolen refresh token may remain usable for a long time.
With rotation:
- each refresh call issues a new refresh token,
- old refresh token is invalidated immediately,
- server stores token family / session chain to detect replay.
Old Token
rtk_v1
Marked as invalid after rotation
New Token
rtk_v2
Only latest refresh token is accepted
Benefits
- Replay detection becomes possible.
- Session hijacking window is reduced.
- You can kill compromised token families quickly.
4. Storage Strategy on Web
Storage choice matters more than many teams expect.
Access in Memory
Least persistence, safer against casual token leak.
Refresh in HttpOnly Cookie
Preferred: hidden from JS and sent securely by browser.
LocalStorage
Convenient but vulnerable to XSS token exfiltration.
Common strategy in production
- Keep access token in memory (or very short-lived state).
- Keep refresh token in HttpOnly, Secure, SameSite cookie.
- Never expose refresh token to frontend JavaScript if avoidable.
Why avoid localStorage for refresh token
If XSS happens, attacker can read localStorage and exfiltrate tokens instantly.
5. Handling Concurrency and Race Conditions
When access token expires, many requests may fail at once.
Bad behavior:
- every failed request triggers its own refresh call,
- token overwrites each other,
- request storm on auth service.
Good behavior:
- use a single-flight refresh promise,
- queue pending requests while refreshing,
- replay queued requests after successful refresh.
Pending requests
Refresh controller
Mode: Single-flight enabled
Keep one refresh request only, queue the rest to avoid token overwrite.
Single-flight pattern idea
- If refresh already running, await the same promise.
- If not running, create one refresh promise.
- After success/failure, clear shared promise.
6. Backend Checks You Should Not Skip
At /auth/refresh, validate at least:
- token signature and expiration,
- token is active in server session store,
- device/session fingerprint (when applicable),
- rotation version / family consistency,
- abnormal frequency or geo/ip anomalies.
Also make sure logout invalidates refresh sessions server-side.
7. Client-Side React Implementation with fetch
This fetch pattern uses single-flight refresh to ensure only one refresh request runs at a time.
FETCH REFRESH FLOW
import { useMemo } from "react";
type ApiClient = {
get: (url: string, init?: RequestInit) => Promise<Response>;
};
export function useApiClient(getAccessToken: () => string | null): ApiClient {
const client = useMemo(() => {
let refreshPromise: Promise<void> | null = null;
const refreshAccessToken = async () => {
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) {
throw new Error("Refresh failed");
}
};
const runRefreshSingleFlight = async () => {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
await refreshPromise;
};
const request = async (url: string, init: RequestInit = {}) => {
const token = getAccessToken();
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status !== 401) {
return res;
}
await runRefreshSingleFlight();
const retriedToken = getAccessToken();
return fetch(url, {
...init,
credentials: "include",
headers: {
...(init.headers || {}),
...(retriedToken ? { Authorization: `Bearer ${retriedToken}` } : {}),
},
});
};
return {
get: (url, init) => request(url, { ...init, method: "GET" }),
};
}, [getAccessToken]);
return client;
}Key points:
- Use
credentials: 'include'if refresh token is in HttpOnly cookie. - Avoid parallel refresh calls when many requests fail with
401. - If refresh fails, clear auth state and redirect to login.
8. React Implementation with axios Interceptors
Axios interceptors let you centralize token attach/retry logic.
AXIOS INTERCEPTOR FLOW
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
type TokensStore = {
getAccessToken: () => string | null;
setAccessToken: (token: string) => void;
clear: () => void;
};
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
export function createAxiosClient(store: TokensStore) {
const api = axios.create({
baseURL: "/api",
withCredentials: true,
});
let refreshPromise: Promise<string> | null = null;
const runRefreshSingleFlight = async () => {
if (!refreshPromise) {
refreshPromise = api
.post("/auth/refresh")
.then((res) => {
const nextToken = res.data?.accessToken as string;
store.setAccessToken(nextToken);
return nextToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
};
api.interceptors.request.use((config) => {
const token = store.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetryConfig | undefined;
if (!original || error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
original._retry = true;
try {
const nextToken = await runRefreshSingleFlight();
original.headers = original.headers || {};
original.headers.Authorization = `Bearer ${nextToken}`;
return api(original);
} catch (refreshError) {
store.clear();
return Promise.reject(refreshError);
}
}
);
return api;
}Interceptor safety tips:
- Add
_retryguard to prevent infinite loops. - Share a single
refreshPromiseacross all requests. - Fail closed when refresh fails (clear state, force re-auth).
9. Failure Scenarios and UX Decisions
Your UX must define what to do when refresh fails.
Recommended path:
- If refresh fails once due to network: retry with short backoff.
- If refresh returns auth error: clear local auth state and redirect to login.
- Show deterministic message: "Session expired, please sign in again.".
Avoid silent infinite loops.
10. Practical Checklist
- Access token TTL: short (for example 5-15 minutes).
- Proactive refresh window: refresh about 10-30 seconds before expiry.
- Refresh token TTL: longer but bounded by business risk.
- Rotate refresh token on each use.
- Keep refresh token in HttpOnly cookie.
- Implement single-flight refresh lock on client.
- Add server revocation + anomaly logging.
11. Closing Thoughts
Refresh token is not just a convenience feature. It is a session security architecture decision.
Done right, it gives both:
- good UX (no random logout),
- better security posture (short-lived access + revocable sessions).
If your team currently uses long-lived access tokens without rotation, improving this area is usually one of the highest-impact auth upgrades.