Refresh Token on the Web: Why, How, and Production Patterns

Hoang Vu,7 min read

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.
TOKEN LIFECYCLE / CYCLE #1

Client State

Access token expires quickly.

Refresh token lives longer.

Access Token TTL

8s

Refresh Action

Idle

Real-world problem this solves

  1. Keep users signed in without making access tokens long-lived.
  2. Limit damage if access token leaks.
  3. 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:

  1. Access token TTL should be short (commonly 5-15 minutes).
  2. Refresh token TTL can be longer (days or weeks) but must support revocation and rotation.
  3. 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:

  1. in-flight requests failing with 401,
  2. client/server clock skew issues,
  3. 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:

  1. User logs in.
  2. Server issues access token + refresh token.
  3. Client calls protected APIs with access token.
  4. API returns 401 when access token expires.
  5. Client calls /auth/refresh using refresh token.
  6. Server validates refresh session and returns new access token.
  7. 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:

  1. each refresh call issues a new refresh token,
  2. old refresh token is invalidated immediately,
  3. server stores token family / session chain to detect replay.
REFRESH TOKEN ROTATION

Old Token

rtk_v1

Marked as invalid after rotation

Waiting

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.

STORAGE STRATEGY

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

  1. Keep access token in memory (or very short-lived state).
  2. Keep refresh token in HttpOnly, Secure, SameSite cookie.
  3. 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.
CONCURRENT 401 HANDLING

Pending requests

req#1 waiting for fresh access token
req#2 waiting for fresh access token
req#3 waiting for fresh access token

Refresh controller

Idle

Mode: Single-flight enabled

Keep one refresh request only, queue the rest to avoid token overwrite.

Single-flight pattern idea

  1. If refresh already running, await the same promise.
  2. If not running, create one refresh promise.
  3. After success/failure, clear shared promise.

6. Backend Checks You Should Not Skip

At /auth/refresh, validate at least:

  1. token signature and expiration,
  2. token is active in server session store,
  3. device/session fingerprint (when applicable),
  4. rotation version / family consistency,
  5. 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

1. API call with access token
2. 401 returned
3. Acquire refresh lock
4. POST /auth/refresh
5. Retry queued requests
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:

  1. Use credentials: 'include' if refresh token is in HttpOnly cookie.
  2. Avoid parallel refresh calls when many requests fail with 401.
  3. 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

Request interceptor
Attach Authorization header
Response interceptor catches 401
Single refresh request
Replay original request
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:

  1. Add _retry guard to prevent infinite loops.
  2. Share a single refreshPromise across all requests.
  3. 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:

  1. If refresh fails once due to network: retry with short backoff.
  2. If refresh returns auth error: clear local auth state and redirect to login.
  3. 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.

2026 © @hoag/blog.