Proper session management is crucial for both security and user experience. Here’s a comprehensive approach to handling token expiration and refresh in React applications:
Core Implementation Patterns
1. Token Refresh Flow
// Auth service module
const refreshToken = async (refreshToken) => {
try {
const response = await axios.post('/auth/refresh', { refreshToken });
return {
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
expiresIn: response.data.expiresIn
};
} catch (error) {
throw new Error('Refresh token failed');
}
};
2. Axios Interceptor Setup
// apiClient.js
const apiClient = axios.create({ baseURL: API_BASE_URL });
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newTokens = await authService.refreshToken(
authStore.getRefreshToken()
);
authStore.setTokens(newTokens);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
authStore.logout();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
React-Specific Implementation
1. Auth Context Provider
const AuthProvider = ({ children }) => {
const [authState, setAuthState] = useState({
accessToken: null,
refreshToken: null,
expiresAt: null
});
const refreshAuth = async () => {
try {
const newTokens = await authService.refreshToken(authState.refreshToken);
setAuthState({
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
expiresAt: Date.now() + newTokens.expiresIn * 1000
});
return true;
} catch (error) {
logout();
return false;
}
};
// Check token expiration before rendering protected routes
const isAuthenticated = () => {
if (!authState.accessToken || !authState.expiresAt) return false;
// Add 5 minute buffer to prevent edge cases
return authState.expiresAt > Date.now() + 300000;
};
return (
<AuthContext.Provider value={{ ...authState, refreshAuth, isAuthenticated }}>
{children}
</AuthContext.Provider>
);
};
2. Protected Route Component
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, refreshAuth } = useAuth();
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
const checkAuth = async () => {
if (!isAuthenticated()) {
setIsRefreshing(true);
const refreshed = await refreshAuth();
if (!refreshed) {
// Redirect to login if refresh fails
navigate('/login');
}
setIsRefreshing(false);
}
};
checkAuth();
}, [isAuthenticated, refreshAuth]);
if (isRefreshing) return <LoadingScreen />;
return isAuthenticated() ? children : null;
};
Advanced Techniques
1. Silent Refresh (Background Token Renewal)
// In your AuthProvider component
useEffect(() => {
if (!authState.accessToken) return;
const expiresIn = authState.expiresAt - Date.now();
const refreshBuffer = 5 * 60 * 1000; // 5 minutes before expiry
// Set timeout to refresh token before it expires
const refreshTimer = setTimeout(async () => {
try {
await refreshAuth();
} catch (error) {
console.error('Background refresh failed:', error);
}
}, expiresIn - refreshBuffer);
return () => clearTimeout(refreshTimer);
}, [authState.accessToken, authState.expiresAt]);
2. Concurrent Request Handling
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
}).catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newTokens = await authService.refreshToken(
authStore.getRefreshToken()
);
authStore.setTokens(newTokens);
processQueue(null, newTokens.accessToken);
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
authStore.logout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
Error Handling and User Experience
1. Session Expiry Notification
// Custom hook for session monitoring
const useSessionTimeout = (logoutCallback) => {
useEffect(() => {
const checkSession = async () => {
try {
await authService.checkSession(); // Simple endpoint that validates token
} catch (error) {
if (error.response?.status === 401) {
showTimeoutNotification(() => {
logoutCallback();
});
}
}
};
const interval = setInterval(checkSession, 300000); // Check every 5 minutes
return () => clearInterval(interval);
}, [logoutCallback]);
};
2. Graceful Logout Handling
const logout = async (isSessionExpired = false) => {
try {
await authService.revokeToken(authState.refreshToken);
} catch (error) {
console.error('Token revocation failed:', error);
} finally {
setAuthState({
accessToken: null,
refreshToken: null,
expiresAt: null
});
if (isSessionExpired) {
navigate('/login?session_expired=true');
} else {
navigate('/login');
}
}
};
Security Considerations
1. Token Storage
// Secure token storage with httpOnly cookies is preferred
// If using localStorage:
const secureStorage = {
setItem: (key, value) => {
try {
const encrypted = encrypt(value);
localStorage.setItem(key, encrypted);
} catch (error) {
console.error('Secure storage failed:', error);
}
},
getItem: (key) => {
try {
const value = localStorage.getItem(key);
return value ? decrypt(value) : null;
} catch (error) {
console.error('Secure retrieval failed:', error);
return null;
}
}
};
2. Short-Lived Tokens
// Recommended token lifetimes:
// - Access token: 15-30 minutes
// - Refresh token: 7-30 days (with rotation)
Testing Strategies
1. Mock API Responses
// Jest test example
describe('Token refresh flow', () => {
beforeEach(() => {
axiosMock.onPost('/auth/refresh').replyOnce(200, {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresIn: 1800
});
});
it('should refresh token when 401 received', async () => {
axiosMock.onGet('/protected').replyOnce(401);
axiosMock.onGet('/protected').replyOnce(200, { data: 'success' });
const response = await apiClient.get('/protected');
expect(response.data).toBe('success');
});
});
2. Integration Tests
describe('ProtectedRoute', () => {
it('should redirect to login when session expires', async () => {
render(
<AuthProvider initialTokens={{ expiresAt: Date.now() - 1000 }}>
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
</AuthProvider>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login');
});
});
});
Best Practices
- Implement proper token rotation – Issue new refresh token on each refresh
- Use secure cookie attributes –
SameSite
,Secure
,HttpOnly
- Monitor refresh token usage – Detect suspicious activity
- Provide clear user feedback – When session is about to expire
- Log token refresh events – For security auditing
- Limit refresh token reuse – Implement one-time-use policy
- Consider device fingerprinting – For additional security