A social knowledge tool for researchers built on ATProto
1# Token Refreshing Strategy
2
3This 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.
4
5## Overview
6
7Our token refresh strategy uses a **layered architecture** with clear separation of responsibilities:
8
91. **TokenManager**: Centralized token logic with automatic refresh
102. **Storage Abstraction**: Pluggable storage implementations for client/server
113. **Token Refresher**: Dedicated service for refresh API calls
124. **Proactive Refresh**: Automatically refresh tokens before they expire (every 5 minutes)
135. **Clean BaseClient**: Only handles HTTP requests, delegates token management
14
15## Architecture
16
17### 1. Core Interfaces
18
19```typescript
20// src/webapp/services/TokenManager.ts
21export interface AuthTokens {
22 accessToken: string | null;
23 refreshToken: string | null;
24}
25
26export interface TokenStorage {
27 getTokens(): AuthTokens;
28 setTokens(accessToken: string, refreshToken: string): Promise<void>;
29 clearTokens(): void;
30}
31
32export interface TokenRefresher {
33 refreshTokens(
34 refreshToken: string,
35 ): Promise<{ accessToken: string; refreshToken: string }>;
36}
37```
38
39### 2. TokenManager - Central Token Logic
40
41The `TokenManager` handles token access and reactive refresh on authentication errors:
42
43```typescript
44// src/webapp/services/TokenManager.ts
45export class TokenManager {
46 private isRefreshing = false;
47 private refreshPromise: Promise<boolean> | null = null;
48 private failedRequestsQueue: Array<{
49 resolve: (value: any) => void;
50 reject: (error: any) => void;
51 request: () => Promise<any>;
52 }> = [];
53
54 constructor(
55 private storage: TokenStorage,
56 private refresher: TokenRefresher,
57 ) {}
58
59 async getAccessToken(): Promise<string | null> {
60 const { accessToken } = this.storage.getTokens();
61 return accessToken;
62 }
63
64 async handleAuthError<T>(originalRequest: () => Promise<T>): Promise<T> {
65 // If already refreshing, queue this request
66 if (this.isRefreshing) {
67 return new Promise((resolve, reject) => {
68 this.failedRequestsQueue.push({
69 resolve,
70 reject,
71 request: originalRequest,
72 });
73 });
74 }
75
76 // Start refresh process
77 this.isRefreshing = true;
78
79 try {
80 // Use existing refresh promise or create new one
81 if (!this.refreshPromise) {
82 this.refreshPromise = this.performRefresh();
83 }
84
85 const refreshSuccess = await this.refreshPromise;
86
87 if (refreshSuccess) {
88 // Process queued requests
89 const queuedRequests = [...this.failedRequestsQueue];
90 this.failedRequestsQueue = [];
91
92 // Retry all queued requests
93 queuedRequests.forEach(async ({ resolve, reject, request }) => {
94 try {
95 const result = await request();
96 resolve(result);
97 } catch (error) {
98 reject(error);
99 }
100 });
101
102 // Retry original request
103 return await originalRequest();
104 } else {
105 // Refresh failed, reject all queued requests
106 const queuedRequests = [...this.failedRequestsQueue];
107 this.failedRequestsQueue = [];
108
109 const refreshError = new Error('Token refresh failed');
110 queuedRequests.forEach(({ reject }) => reject(refreshError));
111
112 throw refreshError;
113 }
114 } finally {
115 this.isRefreshing = false;
116 this.refreshPromise = null;
117 }
118 }
119
120 private async performRefresh(): Promise<boolean> {
121 const { refreshToken } = this.storage.getTokens();
122 if (!refreshToken) return false;
123
124 try {
125 const newTokens = await this.refresher.refreshTokens(refreshToken);
126 await this.storage.setTokens(
127 newTokens.accessToken,
128 newTokens.refreshToken,
129 );
130 return true;
131 } catch (error) {
132 console.error('Token refresh failed:', error);
133 this.storage.clearTokens();
134 return false;
135 }
136 }
137}
138```
139
140### 3. Storage Implementations
141
142**Client-side storage** with localStorage and cookie sync:
143
144```typescript
145// src/webapp/services/storage/ClientTokenStorage.ts
146export class ClientTokenStorage implements TokenStorage {
147 getTokens(): AuthTokens {
148 if (typeof window === 'undefined') {
149 return { accessToken: null, refreshToken: null };
150 }
151
152 return {
153 accessToken: localStorage.getItem('accessToken'),
154 refreshToken: localStorage.getItem('refreshToken'),
155 };
156 }
157
158 async setTokens(accessToken: string, refreshToken: string): Promise<void> {
159 if (typeof window === 'undefined') return;
160
161 localStorage.setItem('accessToken', accessToken);
162 localStorage.setItem('refreshToken', refreshToken);
163
164 // Sync with server cookies
165 try {
166 await fetch('/api/auth/sync', {
167 method: 'POST',
168 headers: { 'Content-Type': 'application/json' },
169 body: JSON.stringify({ accessToken, refreshToken }),
170 credentials: 'include',
171 });
172 } catch (error) {
173 console.warn('Failed to sync tokens with server:', error);
174 }
175 }
176
177 clearTokens(): void {
178 if (typeof window === 'undefined') return;
179 localStorage.removeItem('accessToken');
180 localStorage.removeItem('refreshToken');
181 }
182}
183```
184
185**Server-side storage** (read-only from cookies):
186
187```typescript
188// src/webapp/services/storage/ServerTokenStorage.ts
189export class ServerTokenStorage implements TokenStorage {
190 constructor(private cookiesStore: any) {}
191
192 getTokens(): AuthTokens {
193 return {
194 accessToken: this.cookiesStore.get('accessToken')?.value || null,
195 refreshToken: this.cookiesStore.get('refreshToken')?.value || null,
196 };
197 }
198
199 async setTokens(): Promise<void> {
200 // Server-side can't set tokens - handled by client-side sync
201 throw new Error('Server-side token refresh not supported');
202 }
203
204 clearTokens(): void {
205 // Server-side can't clear tokens directly
206 throw new Error('Server-side token clearing not supported');
207 }
208}
209```
210
211### 4. Token Refresher Service
212
213```typescript
214// src/webapp/services/TokenRefresher.ts
215export class ApiTokenRefresher implements TokenRefresher {
216 constructor(private baseUrl: string) {}
217
218 async refreshTokens(
219 refreshToken: string,
220 ): Promise<{ accessToken: string; refreshToken: string }> {
221 const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
222 method: 'POST',
223 headers: { 'Content-Type': 'application/json' },
224 body: JSON.stringify({ refreshToken }),
225 });
226
227 if (!response.ok) {
228 throw new Error('Token refresh failed');
229 }
230
231 return response.json();
232 }
233}
234```
235
236### 5. Clean BaseClient with Reactive Refresh
237
238The `BaseClient` handles HTTP requests and reactive token refresh on auth errors:
239
240```typescript
241// src/webapp/api-client/clients/BaseClient.ts
242export abstract class BaseClient {
243 constructor(
244 protected baseUrl: string,
245 protected tokenManager: TokenManager,
246 ) {}
247
248 protected async request<T>(
249 method: string,
250 endpoint: string,
251 data?: any,
252 ): Promise<T> {
253 const makeRequest = async (): Promise<T> => {
254 const url = `${this.baseUrl}${endpoint}`;
255 const token = await this.tokenManager.getAccessToken();
256
257 const headers: Record<string, string> = {
258 'Content-Type': 'application/json',
259 };
260
261 if (token) {
262 headers['Authorization'] = `Bearer ${token}`;
263 }
264
265 const config: RequestInit = {
266 method,
267 headers,
268 };
269
270 if (
271 data &&
272 (method === 'POST' || method === 'PUT' || method === 'PATCH')
273 ) {
274 config.body = JSON.stringify(data);
275 }
276
277 const response = await fetch(url, config);
278 return this.handleResponse<T>(response);
279 };
280
281 try {
282 return await makeRequest();
283 } catch (error) {
284 // Handle 401/403 errors with automatic token refresh
285 if (
286 error instanceof ApiError &&
287 (error.status === 401 || error.status === 403)
288 ) {
289 return this.tokenManager.handleAuthError(makeRequest);
290 }
291 throw error;
292 }
293 }
294
295 private async handleResponse<T>(response: Response): Promise<T> {
296 if (!response.ok) {
297 let errorData: ApiErrorResponse;
298 try {
299 errorData = await response.json();
300 } catch {
301 errorData = { message: response.statusText || 'Unknown error' };
302 }
303
304 throw new ApiError(
305 errorData.message,
306 response.status,
307 errorData.code,
308 errorData.details,
309 );
310 }
311
312 return response.json();
313 }
314}
315```
316
317### 6. Factory Functions for Easy Setup
318
319```typescript
320// src/webapp/services/auth.ts
321import { TokenManager } from './TokenManager';
322import { ClientTokenStorage } from './storage/ClientTokenStorage';
323import { ServerTokenStorage } from './storage/ServerTokenStorage';
324import { ApiTokenRefresher } from './TokenRefresher';
325
326// Client-side token manager
327export const createClientTokenManager = () => {
328 const storage = new ClientTokenStorage();
329 const refresher = new ApiTokenRefresher(
330 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
331 );
332 return new TokenManager(storage, refresher);
333};
334
335// Server-side token manager (read-only, no refresh capability)
336export const createServerTokenManager = async () => {
337 const { cookies } = await import('next/headers');
338 const cookiesStore = await cookies();
339 const storage = new ServerTokenStorage(cookiesStore);
340 const refresher = new ApiTokenRefresher(''); // Won't be used
341 return new TokenManager(storage, refresher);
342};
343```
344
345### 7. Updated ApiClient
346
347```typescript
348// src/webapp/api-client/ApiClient.ts
349export class ApiClient {
350 constructor(
351 private baseUrl: string,
352 private tokenManager: TokenManager,
353 ) {
354 this.queryClient = new QueryClient(baseUrl, tokenManager);
355 this.cardClient = new CardClient(baseUrl, tokenManager);
356 this.collectionClient = new CollectionClient(baseUrl, tokenManager);
357 this.userClient = new UserClient(baseUrl, tokenManager);
358 this.feedClient = new FeedClient(baseUrl, tokenManager);
359 }
360 // ... rest stays the same
361}
362
363// Default client instances
364export const apiClient = new ApiClient(
365 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
366 createClientTokenManager(),
367);
368
369export const createServerApiClient = async () => {
370 const tokenManager = await createServerTokenManager();
371 return new ApiClient(
372 process.env.API_BASE_URL || 'http://localhost:3000',
373 tokenManager,
374 );
375};
376```
377
378## Refresh Strategy
379
380### Proactive Token Refresh (Primary Strategy)
381
382The **primary strategy** is proactive refresh in the auth hook to prevent most token expiration scenarios:
383
384```typescript
385// src/webapp/hooks/useAuth.tsx
386export const AuthProvider = ({ children }: { children: ReactNode }) => {
387 // ... existing state
388
389 const tokenManager = useMemo(() => createClientTokenManager(), []);
390
391 // Helper function to check if a JWT token is expired or will expire soon
392 const isTokenExpired = (
393 token: string,
394 bufferMinutes: number = 5,
395 ): boolean => {
396 if (!token) return true;
397
398 try {
399 const payload = JSON.parse(atob(token.split('.')[1]));
400 const expiry = payload.exp * 1000; // Convert to milliseconds
401 const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds
402 return Date.now() >= expiry - bufferTime;
403 } catch (e) {
404 return true;
405 }
406 };
407
408 // PROACTIVE TOKEN REFRESH - This is the primary strategy
409 useEffect(() => {
410 if (!accessToken || !refreshToken) return;
411
412 const checkAndRefreshToken = async () => {
413 // Check if token will expire in the next 5 minutes
414 if (isTokenExpired(accessToken, 5)) {
415 console.log('Proactively refreshing token before expiration');
416 await refreshTokens();
417 }
418 };
419
420 // Check immediately on mount
421 checkAndRefreshToken();
422
423 // Then check every 5 minutes
424 const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000);
425
426 return () => clearInterval(interval);
427 }, [accessToken, refreshToken, refreshTokens]);
428
429 // ... rest of the provider
430};
431```
432
433### Reactive Token Refresh (Safety Net)
434
435The **secondary strategy** is reactive refresh when API calls return authentication errors (401/403). This handles edge cases where proactive refresh didn't occur:
436
437- User device was sleeping during proactive refresh window
438- Browser tab was throttled in background
439- Network issues prevented proactive refresh
440- Server-side rendering scenarios
441
442The TokenManager automatically:
443
4441. **Detects auth errors** (401/403) from API responses
4452. **Refreshes tokens** using the refresh token
4463. **Retries the original request** with fresh tokens
4474. **Queues concurrent requests** to prevent race conditions
448
449## Usage Examples
450
451### Client Components (Simplified)
452
453```typescript
454// src/webapp/app/(authenticated)/cards/[cardId]/page.tsx
455'use client';
456
457import { apiClient } from '@/api-client';
458
459export default function CardPage() {
460 const [card, setCard] = useState<GetUrlCardViewResponse | null>(null);
461
462 useEffect(() => {
463 const fetchCard = async () => {
464 try {
465 setLoading(true);
466 // Token refresh is automatic!
467 const response = await apiClient.getUrlCardView(cardId);
468 setCard(response);
469 } catch (error: any) {
470 console.error('Error fetching card:', error);
471 setError(error.message || 'Failed to load card');
472 } finally {
473 setLoading(false);
474 }
475 };
476
477 if (cardId) {
478 fetchCard();
479 }
480 }, [cardId]);
481
482 // ... rest of component
483}
484```
485
486### Server Components
487
488```typescript
489// Server component usage
490import { createServerApiClient } from '@/services/auth';
491
492export default async function SSRProfilePage() {
493 const apiClient = await createServerApiClient();
494
495 let profile;
496 let error;
497
498 try {
499 profile = await apiClient.getMyProfile();
500 } catch (err: any) {
501 error = err.message || 'Failed to load profile';
502 }
503
504 // ... rest of component
505}
506```
507
508## Benefits of This Architecture
509
510✅ **Single Responsibility**: Each class has one clear purpose
511✅ **Testable**: Easy to mock TokenStorage and TokenRefresher
512✅ **Flexible**: Different storage strategies for client/server
513✅ **Clean BaseClient**: Only handles HTTP requests and auth error detection
514✅ **Consistent API**: Same interface everywhere
515✅ **Future-proof**: Easy to add new storage backends
516✅ **Race Condition Safe**: TokenManager prevents concurrent refreshes
517✅ **Automatic Recovery**: Failed requests are automatically retried after refresh
518✅ **Request Queuing**: Multiple concurrent requests handled gracefully
519✅ **Server-Side Support**: Works in SSR with cookie-based tokens
520
521## Server-Side Token Refresh Limitation
522
523**Current Approach**: Server components use read-only token access from cookies. If tokens are expired, the request will fail gracefully.
524
525**Why This Works**:
526
527- Proactive refresh on client-side prevents most expiration scenarios
528- Server components typically render fresh on each request
529- Failed server requests degrade gracefully (show error states)
530- Client-side components can still refresh and retry
531
532**Future Enhancements** (if needed):
533
534- Add middleware to check token expiry and redirect to refresh endpoint
535- Implement server-side refresh with secure cookie updates
536- Use hybrid approach with client-side refresh signals
537
538## Token Timing Strategy
539
540- **Access Token Lifetime**: 15 minutes (server-configured)
541- **Proactive Refresh**: Every 5 minutes, refresh if expiring within 5 minutes
542- **Reactive Refresh**: Only on 401/403 API errors (no preemptive checking)
543- **Refresh Token Lifetime**: 7 days (server-configured)
544
545This means:
546
547- **99% of cases**: Tokens refreshed proactively at 10 minutes (before 15-minute expiry)
548- **1% of cases**: Reactive refresh handles edge cases when proactive refresh missed
549- **Zero failed requests**: All auth errors automatically trigger refresh and retry
550- Users can be inactive for up to 7 days and still auto-login
551
552## Implementation Checklist
553
554- [ ] Create `TokenManager` class with refresh logic
555- [ ] Implement `ClientTokenStorage` and `ServerTokenStorage`
556- [ ] Create `ApiTokenRefresher` service
557- [ ] Update `BaseClient` to use `TokenManager`
558- [ ] Update `ApiClient` constructor
559- [ ] Create factory functions in auth service
560- [ ] Enhance `useAuth` hook with proactive refresh
561- [ ] Create default `apiClient` export
562- [ ] Update components to use default `apiClient`
563- [ ] Test token refresh scenarios
564- [ ] Monitor refresh frequency in production
565
566## How It Works
567
568### Typical Flow (99% of cases)
569
5701. **User loads app** → Auth hook starts proactive refresh timer
5712. **Every 5 minutes** → Check if token expires in next 5 minutes
5723. **At 10 minutes** → Proactively refresh token (5 minutes before 15-minute expiry)
5734. **API calls** → Use fresh token, no refresh needed
5745. **Seamless experience** → User never sees authentication errors
575
576### Edge Case Flow (1% of cases)
577
5781. **API call made** → Token happens to be expired (proactive refresh missed)
5792. **Server returns 401/403** → BaseClient catches the error
5803. **TokenManager.handleAuthError()** → Automatically refresh tokens
5814. **Retry original request** → With fresh token, request succeeds
5825. **Queue concurrent requests** → Other API calls wait for refresh, then retry
5836. **User sees success** → Brief delay, but no visible error
584
585### Race Condition Handling
586
587```typescript
588// Multiple API calls happen when token is expired
589apiClient.getProfile(); // Triggers refresh
590apiClient.getCards(); // Queued, waits for refresh
591apiClient.getCollections(); // Queued, waits for refresh
592
593// After refresh completes:
594// - All three requests retry with fresh token
595// - All succeed without user seeing errors
596```
597
598## Monitoring
599
600Consider tracking these metrics:
601
602- **Proactive refresh frequency** (should be regular, every ~5 minutes)
603- **Reactive refresh frequency** (should be rare, <1% of API calls)
604- **Failed refresh attempts** (should investigate if >0.1%)
605- **Average time between refreshes** (should be ~5 minutes)
606- **Token refresh success rate** (should be >99.9%)
607- **Queued request count** (indicates concurrent API calls during refresh)
608
609## Migration Guide
610
611### From Old Approach
612
613**Before:**
614
615```typescript
616const apiClient = new ApiClient(
617 baseUrl,
618 () => getAccessToken(),
619 refreshTokens, // Had to pass refresh function
620);
621```
622
623**After:**
624
625```typescript
626import { apiClient } from '@/api-client';
627// Just use the default instance - no configuration needed!
628```
629
630### Key Changes
631
6321. **TokenManager**: Centralized token logic replaces scattered refresh code
6332. **Storage Abstraction**: Pluggable storage for different environments
6343. **Clean BaseClient**: Only handles HTTP, delegates token management
6354. **Factory Functions**: Easy setup for client/server scenarios
6365. **Default Export**: Use `apiClient` instead of creating instances