Token Refreshing Strategy#
This document outlines our approach to automatic token refreshing with clean separation of concerns, combining proactive and reactive strategies to ensure seamless user experience with minimal API failures.
Overview#
Our token refresh strategy uses a layered architecture with clear separation of responsibilities:
- TokenManager: Centralized token logic with automatic refresh
- Storage Abstraction: Pluggable storage implementations for client/server
- Token Refresher: Dedicated service for refresh API calls
- Proactive Refresh: Automatically refresh tokens before they expire (every 5 minutes)
- Clean BaseClient: Only handles HTTP requests, delegates token management
Architecture#
1. Core Interfaces#
// src/webapp/services/TokenManager.ts
export interface AuthTokens {
accessToken: string | null;
refreshToken: string | null;
}
export interface TokenStorage {
getTokens(): AuthTokens;
setTokens(accessToken: string, refreshToken: string): Promise<void>;
clearTokens(): void;
}
export interface TokenRefresher {
refreshTokens(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string }>;
}
2. TokenManager - Central Token Logic#
The TokenManager handles token access and reactive refresh on authentication errors:
// src/webapp/services/TokenManager.ts
export class TokenManager {
private isRefreshing = false;
private refreshPromise: Promise<boolean> | null = null;
private failedRequestsQueue: Array<{
resolve: (value: any) => void;
reject: (error: any) => void;
request: () => Promise<any>;
}> = [];
constructor(
private storage: TokenStorage,
private refresher: TokenRefresher,
) {}
async getAccessToken(): Promise<string | null> {
const { accessToken } = this.storage.getTokens();
return accessToken;
}
async handleAuthError<T>(originalRequest: () => Promise<T>): Promise<T> {
// If already refreshing, queue this request
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedRequestsQueue.push({
resolve,
reject,
request: originalRequest,
});
});
}
// Start refresh process
this.isRefreshing = true;
try {
// Use existing refresh promise or create new one
if (!this.refreshPromise) {
this.refreshPromise = this.performRefresh();
}
const refreshSuccess = await this.refreshPromise;
if (refreshSuccess) {
// Process queued requests
const queuedRequests = [...this.failedRequestsQueue];
this.failedRequestsQueue = [];
// Retry all queued requests
queuedRequests.forEach(async ({ resolve, reject, request }) => {
try {
const result = await request();
resolve(result);
} catch (error) {
reject(error);
}
});
// Retry original request
return await originalRequest();
} else {
// Refresh failed, reject all queued requests
const queuedRequests = [...this.failedRequestsQueue];
this.failedRequestsQueue = [];
const refreshError = new Error('Token refresh failed');
queuedRequests.forEach(({ reject }) => reject(refreshError));
throw refreshError;
}
} finally {
this.isRefreshing = false;
this.refreshPromise = null;
}
}
private async performRefresh(): Promise<boolean> {
const { refreshToken } = this.storage.getTokens();
if (!refreshToken) return false;
try {
const newTokens = await this.refresher.refreshTokens(refreshToken);
await this.storage.setTokens(
newTokens.accessToken,
newTokens.refreshToken,
);
return true;
} catch (error) {
console.error('Token refresh failed:', error);
this.storage.clearTokens();
return false;
}
}
}
3. Storage Implementations#
Client-side storage with localStorage and cookie sync:
// src/webapp/services/storage/ClientTokenStorage.ts
export class ClientTokenStorage implements TokenStorage {
getTokens(): AuthTokens {
if (typeof window === 'undefined') {
return { accessToken: null, refreshToken: null };
}
return {
accessToken: localStorage.getItem('accessToken'),
refreshToken: localStorage.getItem('refreshToken'),
};
}
async setTokens(accessToken: string, refreshToken: string): Promise<void> {
if (typeof window === 'undefined') return;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// Sync with server cookies
try {
await fetch('/api/auth/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken, refreshToken }),
credentials: 'include',
});
} catch (error) {
console.warn('Failed to sync tokens with server:', error);
}
}
clearTokens(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
Server-side storage (read-only from cookies):
// src/webapp/services/storage/ServerTokenStorage.ts
export class ServerTokenStorage implements TokenStorage {
constructor(private cookiesStore: any) {}
getTokens(): AuthTokens {
return {
accessToken: this.cookiesStore.get('accessToken')?.value || null,
refreshToken: this.cookiesStore.get('refreshToken')?.value || null,
};
}
async setTokens(): Promise<void> {
// Server-side can't set tokens - handled by client-side sync
throw new Error('Server-side token refresh not supported');
}
clearTokens(): void {
// Server-side can't clear tokens directly
throw new Error('Server-side token clearing not supported');
}
}
4. Token Refresher Service#
// src/webapp/services/TokenRefresher.ts
export class ApiTokenRefresher implements TokenRefresher {
constructor(private baseUrl: string) {}
async refreshTokens(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
return response.json();
}
}
5. Clean BaseClient with Reactive Refresh#
The BaseClient handles HTTP requests and reactive token refresh on auth errors:
// src/webapp/api-client/clients/BaseClient.ts
export abstract class BaseClient {
constructor(
protected baseUrl: string,
protected tokenManager: TokenManager,
) {}
protected async request<T>(
method: string,
endpoint: string,
data?: any,
): Promise<T> {
const makeRequest = async (): Promise<T> => {
const url = `${this.baseUrl}${endpoint}`;
const token = await this.tokenManager.getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const config: RequestInit = {
method,
headers,
};
if (
data &&
(method === 'POST' || method === 'PUT' || method === 'PATCH')
) {
config.body = JSON.stringify(data);
}
const response = await fetch(url, config);
return this.handleResponse<T>(response);
};
try {
return await makeRequest();
} catch (error) {
// Handle 401/403 errors with automatic token refresh
if (
error instanceof ApiError &&
(error.status === 401 || error.status === 403)
) {
return this.tokenManager.handleAuthError(makeRequest);
}
throw error;
}
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let errorData: ApiErrorResponse;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText || 'Unknown error' };
}
throw new ApiError(
errorData.message,
response.status,
errorData.code,
errorData.details,
);
}
return response.json();
}
}
6. Factory Functions for Easy Setup#
// src/webapp/services/auth.ts
import { TokenManager } from './TokenManager';
import { ClientTokenStorage } from './storage/ClientTokenStorage';
import { ServerTokenStorage } from './storage/ServerTokenStorage';
import { ApiTokenRefresher } from './TokenRefresher';
// Client-side token manager
export const createClientTokenManager = () => {
const storage = new ClientTokenStorage();
const refresher = new ApiTokenRefresher(
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
);
return new TokenManager(storage, refresher);
};
// Server-side token manager (read-only, no refresh capability)
export const createServerTokenManager = async () => {
const { cookies } = await import('next/headers');
const cookiesStore = await cookies();
const storage = new ServerTokenStorage(cookiesStore);
const refresher = new ApiTokenRefresher(''); // Won't be used
return new TokenManager(storage, refresher);
};
7. Updated ApiClient#
// src/webapp/api-client/ApiClient.ts
export class ApiClient {
constructor(
private baseUrl: string,
private tokenManager: TokenManager,
) {
this.queryClient = new QueryClient(baseUrl, tokenManager);
this.cardClient = new CardClient(baseUrl, tokenManager);
this.collectionClient = new CollectionClient(baseUrl, tokenManager);
this.userClient = new UserClient(baseUrl, tokenManager);
this.feedClient = new FeedClient(baseUrl, tokenManager);
}
// ... rest stays the same
}
// Default client instances
export const apiClient = new ApiClient(
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
createClientTokenManager(),
);
export const createServerApiClient = async () => {
const tokenManager = await createServerTokenManager();
return new ApiClient(
process.env.API_BASE_URL || 'http://localhost:3000',
tokenManager,
);
};
Refresh Strategy#
Proactive Token Refresh (Primary Strategy)#
The primary strategy is proactive refresh in the auth hook to prevent most token expiration scenarios:
// src/webapp/hooks/useAuth.tsx
export const AuthProvider = ({ children }: { children: ReactNode }) => {
// ... existing state
const tokenManager = useMemo(() => createClientTokenManager(), []);
// Helper function to check if a JWT token is expired or will expire soon
const isTokenExpired = (
token: string,
bufferMinutes: number = 5,
): boolean => {
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiry = payload.exp * 1000; // Convert to milliseconds
const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds
return Date.now() >= expiry - bufferTime;
} catch (e) {
return true;
}
};
// PROACTIVE TOKEN REFRESH - This is the primary strategy
useEffect(() => {
if (!accessToken || !refreshToken) return;
const checkAndRefreshToken = async () => {
// Check if token will expire in the next 5 minutes
if (isTokenExpired(accessToken, 5)) {
console.log('Proactively refreshing token before expiration');
await refreshTokens();
}
};
// Check immediately on mount
checkAndRefreshToken();
// Then check every 5 minutes
const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [accessToken, refreshToken, refreshTokens]);
// ... rest of the provider
};
Reactive Token Refresh (Safety Net)#
The secondary strategy is reactive refresh when API calls return authentication errors (401/403). This handles edge cases where proactive refresh didn't occur:
- User device was sleeping during proactive refresh window
- Browser tab was throttled in background
- Network issues prevented proactive refresh
- Server-side rendering scenarios
The TokenManager automatically:
- Detects auth errors (401/403) from API responses
- Refreshes tokens using the refresh token
- Retries the original request with fresh tokens
- Queues concurrent requests to prevent race conditions
Usage Examples#
Client Components (Simplified)#
// src/webapp/app/(authenticated)/cards/[cardId]/page.tsx
'use client';
import { apiClient } from '@/api-client';
export default function CardPage() {
const [card, setCard] = useState<GetUrlCardViewResponse | null>(null);
useEffect(() => {
const fetchCard = async () => {
try {
setLoading(true);
// Token refresh is automatic!
const response = await apiClient.getUrlCardView(cardId);
setCard(response);
} catch (error: any) {
console.error('Error fetching card:', error);
setError(error.message || 'Failed to load card');
} finally {
setLoading(false);
}
};
if (cardId) {
fetchCard();
}
}, [cardId]);
// ... rest of component
}
Server Components#
// Server component usage
import { createServerApiClient } from '@/services/auth';
export default async function SSRProfilePage() {
const apiClient = await createServerApiClient();
let profile;
let error;
try {
profile = await apiClient.getMyProfile();
} catch (err: any) {
error = err.message || 'Failed to load profile';
}
// ... rest of component
}
Benefits of This Architecture#
✅ Single Responsibility: Each class has one clear purpose
✅ Testable: Easy to mock TokenStorage and TokenRefresher
✅ Flexible: Different storage strategies for client/server
✅ Clean BaseClient: Only handles HTTP requests and auth error detection
✅ Consistent API: Same interface everywhere
✅ Future-proof: Easy to add new storage backends
✅ Race Condition Safe: TokenManager prevents concurrent refreshes
✅ Automatic Recovery: Failed requests are automatically retried after refresh
✅ Request Queuing: Multiple concurrent requests handled gracefully
✅ Server-Side Support: Works in SSR with cookie-based tokens
Server-Side Token Refresh Limitation#
Current Approach: Server components use read-only token access from cookies. If tokens are expired, the request will fail gracefully.
Why This Works:
- Proactive refresh on client-side prevents most expiration scenarios
- Server components typically render fresh on each request
- Failed server requests degrade gracefully (show error states)
- Client-side components can still refresh and retry
Future Enhancements (if needed):
- Add middleware to check token expiry and redirect to refresh endpoint
- Implement server-side refresh with secure cookie updates
- Use hybrid approach with client-side refresh signals
Token Timing Strategy#
- Access Token Lifetime: 15 minutes (server-configured)
- Proactive Refresh: Every 5 minutes, refresh if expiring within 5 minutes
- Reactive Refresh: Only on 401/403 API errors (no preemptive checking)
- Refresh Token Lifetime: 7 days (server-configured)
This means:
- 99% of cases: Tokens refreshed proactively at 10 minutes (before 15-minute expiry)
- 1% of cases: Reactive refresh handles edge cases when proactive refresh missed
- Zero failed requests: All auth errors automatically trigger refresh and retry
- Users can be inactive for up to 7 days and still auto-login
Implementation Checklist#
- Create
TokenManagerclass with refresh logic - Implement
ClientTokenStorageandServerTokenStorage - Create
ApiTokenRefresherservice - Update
BaseClientto useTokenManager - Update
ApiClientconstructor - Create factory functions in auth service
- Enhance
useAuthhook with proactive refresh - Create default
apiClientexport - Update components to use default
apiClient - Test token refresh scenarios
- Monitor refresh frequency in production
How It Works#
Typical Flow (99% of cases)#
- User loads app → Auth hook starts proactive refresh timer
- Every 5 minutes → Check if token expires in next 5 minutes
- At 10 minutes → Proactively refresh token (5 minutes before 15-minute expiry)
- API calls → Use fresh token, no refresh needed
- Seamless experience → User never sees authentication errors
Edge Case Flow (1% of cases)#
- API call made → Token happens to be expired (proactive refresh missed)
- Server returns 401/403 → BaseClient catches the error
- TokenManager.handleAuthError() → Automatically refresh tokens
- Retry original request → With fresh token, request succeeds
- Queue concurrent requests → Other API calls wait for refresh, then retry
- User sees success → Brief delay, but no visible error
Race Condition Handling#
// Multiple API calls happen when token is expired
apiClient.getProfile(); // Triggers refresh
apiClient.getCards(); // Queued, waits for refresh
apiClient.getCollections(); // Queued, waits for refresh
// After refresh completes:
// - All three requests retry with fresh token
// - All succeed without user seeing errors
Monitoring#
Consider tracking these metrics:
- Proactive refresh frequency (should be regular, every ~5 minutes)
- Reactive refresh frequency (should be rare, <1% of API calls)
- Failed refresh attempts (should investigate if >0.1%)
- Average time between refreshes (should be ~5 minutes)
- Token refresh success rate (should be >99.9%)
- Queued request count (indicates concurrent API calls during refresh)
Migration Guide#
From Old Approach#
Before:
const apiClient = new ApiClient(
baseUrl,
() => getAccessToken(),
refreshTokens, // Had to pass refresh function
);
After:
import { apiClient } from '@/api-client';
// Just use the default instance - no configuration needed!
Key Changes#
- TokenManager: Centralized token logic replaces scattered refresh code
- Storage Abstraction: Pluggable storage for different environments
- Clean BaseClient: Only handles HTTP, delegates token management
- Factory Functions: Easy setup for client/server scenarios
- Default Export: Use
apiClientinstead of creating instances