Refresh Tokens and Silent Refresh โ Keeping Users Logged In Safely
Day 15 of 40 โ React System Design Series

Hi, Iโm Richa โ a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโve learned working on large codebases. My goal is to share practical insights โ not shortcuts โ for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโre serious about improving as an engineer, youโre in the right place.
Hey everyone, Richa here! ๐
Day 15. Last day of Week 3.
And today we're going deep on the piece that makes JWT auth actually usable in production.
Refresh tokens and silent refresh.
On Day 11 I mentioned the JWT invalidation problem โ access tokens expire, and if you make them too short, users keep getting logged out. The solution is access tokens + refresh tokens + silent refresh. Today we build the whole thing.
This is one of those topics where I've seen so many wrong implementations. Tutorials that store the refresh token in localStorage. Implementations that cause infinite refresh loops. Race conditions where multiple requests all try to refresh at the same time.
Today we get it right.
Quick recap โ why we need this
Access tokens are short-lived (15 minutes) so a stolen token is useless quickly.
But a 15-minute expiry means users get logged out every 15 minutes. That's not acceptable.
The solution:
Access token โ short-lived (15 min), used for every API request
Refresh token โ long-lived (7-30 days), stored securely, used ONLY to get new access tokens
When the access token expires, the app silently sends the refresh token to get a new access token โ without the user having to log in again. If the refresh token is also expired (or invalidated on logout) โ then the user logs in again.
The user experience: they stay logged in for weeks. Behind the scenes, their access token rotates every 15 minutes. They never see it happen.
How the full flow works
1. Login
โ Server returns:
- Access token (HttpOnly cookie, 15 min expiry)
- Refresh token (HttpOnly cookie, 7 day expiry)
2. Normal API call
โ Browser sends access token cookie automatically
โ Server verifies access token
โ Returns data โ
3. Access token expires (after 15 min)
โ API call returns 401
4. Silent refresh (triggered by 401)
โ App sends POST /api/auth/refresh
โ Browser sends refresh token cookie automatically
โ Server validates refresh token (checks DB โ can be invalidated)
โ Server issues new access token cookie
โ Server optionally rotates refresh token (issues new one, invalidates old)
โ App retries original request โ
5. Logout
โ App sends POST /api/auth/logout
โ Server deletes refresh token from DB
โ Server clears both cookies
โ Refresh token is now permanently invalid
The user only notices step 1 (the login form) and step 5 (the logout button). Everything else is invisible.
The axios interceptor โ handling 401s silently
We started this on Day 12. Now let's handle the edge cases properly โ especially the race condition.
The race condition: if multiple API requests are in flight when the access token expires, they all get 401s at the same time. Without protection, they all try to call the refresh endpoint simultaneously โ multiple refresh requests, potentially causing issues.
The fix: a refresh queue. If a refresh is already in progress, queue the other requests and resolve them all when the refresh completes.
// services/apiClient.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { authService } from './authService';
// Track if a refresh is currently in progress
let isRefreshing = false;
// Queue of requests waiting for token refresh
let refreshQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
// Process all queued requests after refresh
function processQueue(error: Error | null) {
refreshQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve();
}
});
refreshQueue = [];
}
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true, // sends cookies automatically
});
apiClient.interceptors.response.use(
(response) => response, // success โ pass through
async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Only handle 401s, and only if we haven't already retried this request
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
// Don't try to refresh if the refresh endpoint itself returned 401
if (originalRequest.url?.includes('/auth/refresh')) {
// Refresh failed โ clear auth state and redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
originalRequest._retry = true;
// If a refresh is already happening โ queue this request
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
}).then(() => apiClient(originalRequest))
.catch((err) => Promise.reject(err));
}
// Start the refresh
isRefreshing = true;
try {
await authService.refreshToken(); // POST /api/auth/refresh
processQueue(null); // resume all queued requests
return apiClient(originalRequest); // retry the request that triggered this
} catch (refreshError) {
processQueue(refreshError as Error); // fail all queued requests
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
With the queue in place:
Request A gets 401 โ starts refresh โ
isRefreshing = trueRequest B gets 401 โ sees
isRefreshingโ queuedRequest C gets 401 โ queued
Refresh completes โ
processQueue(null)โ B and C are retriedAll three requests complete successfully
User never saw a single error
Refresh token rotation โ why you should do it
Basic refresh token: one refresh token, used forever until it expires.
Refresh token rotation: every time you use a refresh token, the server issues a new one and invalidates the old one.
First refresh:
Send old refresh token โ get new access token + new refresh token
Old refresh token is now invalid
Second refresh:
Send new refresh token โ get new access token + newer refresh token
Previous refresh token is now invalid
Why this matters: if a refresh token is stolen, the attacker uses it once to get an access token. That use rotates the token. When your app tries to use the original refresh token next time โ it's been invalidated. The server can detect the reuse and invalidate the whole session.
It's not foolproof โ but it limits the attack window significantly and gives you a detection mechanism.
Implement this on the server side. The client-side code doesn't change โ it just sends whatever refresh token it has.
Proactive refresh โ refreshing before expiry
The reactive approach (refresh on 401) works but has one minor issue: the user's request fails and has to be retried. For most apps this is fine โ it's invisible. But for some use cases (file uploads, real-time operations) you want to avoid the 401 altogether.
The proactive approach: check the token expiry and refresh before it happens.
// utils/tokenUtils.ts
// Decode JWT payload (remember โ not secure, just for reading expiry)
function getTokenExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp ?? null;
} catch {
return null;
}
}
function isTokenExpiringSoon(token: string, bufferSeconds = 60): boolean {
const exp = getTokenExpiry(token);
if (!exp) return true;
const now = Math.floor(Date.now() / 1000);
return exp - now < bufferSeconds; // refresh if expiring within 60 seconds
}
// hooks/useTokenRefresh.ts
import { useEffect, useRef } from 'react';
import { authService } from '../services/authService';
import { useAuthContext } from '../context/AuthContext';
export function useTokenRefresh() {
const { isAuthenticated, logout } = useAuthContext();
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
if (!isAuthenticated) return;
// Check every minute if the token needs refreshing
intervalRef.current = setInterval(async () => {
try {
await authService.refreshToken();
} catch {
// Refresh failed โ session is over
await logout();
}
}, 1000 * 60 * 14); // every 14 minutes (access token is 15 min)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isAuthenticated, logout]);
}
Use it in the root layout:
function AppLayout() {
useTokenRefresh(); // silently refreshes every 14 minutes
return <Outlet />;
}
Choose the approach that fits your app:
Reactive (401 interceptor) โ simpler, works for most apps, tiny retry delay
Proactive (interval) โ no retry delay, better for real-time or upload-heavy apps
Many production apps use both โ the interval for routine refresh, and the interceptor as a safety net.
The complete security picture โ putting it all together
Here's what the full auth security model looks like with everything from this week:
Storage
Access token โ HttpOnly cookie (JS can't read it)
Refresh token โ HttpOnly cookie, separate (JS can't read it)
Protection
Both cookies โ Secure flag (HTTPS only)
Both cookies โ SameSite=Strict (no CSRF)
Access token โ 15 min expiry (stolen token useless quickly)
Refresh token โ DB-backed (can be invalidated on logout)
Refresh token โ Rotated on use (stolen token detected on reuse)
React
getMe() โ on mount (session survives refreshes)
isLoading โ true until session check done (no flash)
401 interceptor โ silent refresh + request queue (race condition safe)
Proactive refresh โ every 14 min (avoids failed requests)
This is the same pattern you'd find in a well-built fintech or SaaS app. It's not overkill โ it's just correct.
Week 3 wrap-up
This week we covered:
Day 11 โ JWT vs sessions vs cookies: the concepts most tutorials get wrong
Day 12 โ Building the auth flow: authService, apiClient, useAuth hook
Day 13 โ Protected routes: no flash, role-based access, redirect-back after login
Day 14 โ Auth context: the full AuthProvider that ties it all together
Day 15 โ Refresh tokens and silent refresh: the race condition fix and rotation
The full auth system is built. Every piece connects. And it's secure by default.
Next week: React Architecture & Folder Structure โ the code works, but can a team of 10 maintain it in 6 months?
See you Monday for Day 16 โ Folder structure patterns: feature-based vs type-based, and why it matters.
โ Day 14 โ Auth Context Pattern โ Day 16 โ Folder Structure Patterns
Part of the React System Design Series โ 40 days, one topic per day.






