Loading learning content...
The refresh token pattern elegantly solves the short-vs-long expiration dilemma—but it introduces a new problem. Refresh tokens are high-value targets. A stolen access token grants access for minutes; a stolen refresh token grants access for days, weeks, or longer.
Refresh token rotation is the solution. By issuing a new refresh token with every use and invalidating the old one, we transform a static, long-lived credential into a moving target. If an attacker steals a refresh token, we can detect the theft and invalidate the entire session.
This page teaches you to implement rotation strategies that maximize security while handling the edge cases—concurrent requests, race conditions, and legitimate token reuse—that make rotation challenging in production.
By the end of this page, you will understand why rotation is essential for security, implement one-time-use refresh tokens, design token family tracking to detect theft, handle concurrent request edge cases, and build rotation systems that work at scale.
Before implementing rotation, we must understand why refresh tokens are attractive targets and how attackers exploit them.
Refresh tokens are more valuable than access tokens because:
| Attack Vector | How It Works | Without Rotation | With Rotation |
|---|---|---|---|
| XSS Extraction | Script extracts token from cookie/storage | Attacker has weeks of access | Single use; theft detected on next legitimate refresh |
| Cookie Theft | Session hijacking via stolen cookies | Full session takeover | Theft detected when both parties try to refresh |
| Device Theft | Physical access to device with stored token | Unlimited access until expiration | Token quickly invalidated when victim refreshes |
| MITM (no HTTPS) | Token intercepted in transit | Attacker mirrors victim's session | Race condition; one party loses access (detection signal) |
| Insider Threat | Employee extracts tokens from logs/systems | Extended silent access | Usage detected through family tracking |
The Core Insight:
Without rotation, a stolen refresh token is indistinguishable from the legitimate token—both work identically until expiration. With rotation, any duplication becomes detectable because only one party can successfully use the token.
For applications handling sensitive data (financial, healthcare, government), refresh token rotation isn't a nice-to-have—it's a requirement. Static refresh tokens effectively grant persistent access to anyone who obtains them, defeating the purpose of short-lived access tokens.
Refresh token rotation follows a simple principle: each refresh token can only be used once. When exchanged for new tokens, the old refresh token is immediately invalidated.
The Basic Flow:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
interface RefreshTokenRecord { tokenId: string; // jti - the token's unique identifier userId: string; // Which user this token belongs to familyId: string; // Groups tokens from same login session expiresAt: number; // When this token expires usedAt?: number; // When this token was exchanged (null = unused) replacedBy?: string; // ID of the token that replaced this one} class RefreshTokenService { constructor( private tokenStore: TokenStore, // Redis or database private userService: UserService ) {} /** * Exchange a refresh token for new access + refresh tokens * Implements one-time-use semantics with rotation */ async rotateRefreshToken(oldRefreshToken: string): Promise<TokenPair> { // Step 1: Verify the token signature and expiration const payload = this.verifyRefreshToken(oldRefreshToken); // Step 2: Look up token record const tokenRecord = await this.tokenStore.getRefreshToken(payload.jti!); if (!tokenRecord) { // Token not found - either never existed or already cleaned up throw new InvalidTokenError('Refresh token not found'); } // Step 3: Check if token was already used (CRITICAL CHECK) if (tokenRecord.usedAt) { // TOKEN REUSE DETECTED - SECURITY EVENT! await this.handleTokenReuse(tokenRecord); throw new TokenReuseError('Refresh token was already used'); } // Step 4: Mark the old token as used (atomic operation important!) const marked = await this.tokenStore.markTokenUsed(payload.jti!, Date.now()); if (!marked) { // Race condition: another request marked it first throw new ConcurrentRefreshError('Token already being refreshed'); } // Step 5: Validate the user still exists and is active const user = await this.userService.findById(tokenRecord.userId); if (!user || !user.isActive) { throw new InvalidUserError('User not found or inactive'); } // Step 6: Generate new token pair const newTokenPair = await this.createTokenPair(user, tokenRecord.familyId); // Step 7: Update the old token record to reference the new one await this.tokenStore.updateToken(payload.jti!, { replacedBy: newTokenPair.refreshTokenId, }); return newTokenPair; } /** * Handle token reuse - indicates theft or replay attack */ private async handleTokenReuse(record: RefreshTokenRecord): Promise<void> { console.error('SECURITY: Refresh token reuse detected', { tokenId: record.tokenId, userId: record.userId, familyId: record.familyId, originalUse: new Date(record.usedAt!).toISOString(), }); // Invalidate the ENTIRE token family // This kicks out both the legitimate user AND the attacker await this.tokenStore.invalidateTokenFamily(record.familyId); // Alert security team await this.alertSecurityTeam({ event: 'REFRESH_TOKEN_REUSE', userId: record.userId, familyId: record.familyId, }); // Optionally: force password reset, require MFA, etc. } private verifyRefreshToken(token: string): JwtPayload { return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET!, { algorithms: ['HS256'], }) as JwtPayload; } private async createTokenPair( user: User, familyId: string ): Promise<TokenPair & { refreshTokenId: string }> { const now = Math.floor(Date.now() / 1000); const refreshTokenId = crypto.randomUUID(); // Create access token const accessToken = jwt.sign({ sub: user.id, iat: now, exp: now + 900, // 15 minutes type: 'access', roles: user.roles, }, process.env.ACCESS_TOKEN_SECRET!); // Create new refresh token const refreshToken = jwt.sign({ sub: user.id, jti: refreshTokenId, family: familyId, // Track the family iat: now, exp: now + 604800, // 7 days type: 'refresh', }, process.env.REFRESH_TOKEN_SECRET!); // Store the new refresh token record await this.tokenStore.storeRefreshToken({ tokenId: refreshTokenId, userId: user.id, familyId: familyId, expiresAt: now + 604800, }); return { accessToken, refreshToken, refreshTokenId }; }}The 'mark as used' operation must be atomic. In Redis, use SET with NX (Not eXists) or Lua scripts. In databases, use row-level locking or compare-and-swap. Without atomicity, race conditions can allow a token to be used multiple times.
Token families group all refresh tokens that descend from a single login event. When token reuse is detected, we invalidate the entire family—not just the specific token.
Why families matter:
Imagine this scenario:
Family-based invalidation ensures the attacker cannot maintain access through their already-rotated token.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// Token family chain visualization//// Login Event (creates family_abc)// ↓// RT1 (family: abc)// ↓ refresh// RT2 (family: abc, replacedBy: RT1)// ↓ refresh// RT3 (family: abc, replacedBy: RT2)// ↓ ... //// If RT1 is reused after RT2 exists:// - Detect reuse (RT1.usedAt is set)// - Invalidate family_abc// - All of RT1, RT2, RT3 are now invalid interface TokenFamilyStore { // Store for refresh tokens with family tracking private redis: RedisClient; async storeRefreshToken(record: RefreshTokenRecord): Promise<void> { const key = `refresh_token:${record.tokenId}`; const familyKey = `token_family:${record.familyId}`; // Store the token record await this.redis.setex( key, record.expiresAt - Math.floor(Date.now() / 1000), JSON.stringify(record) ); // Add token to family set await this.redis.sadd(familyKey, record.tokenId); // Set family expiry to match longest possible token await this.redis.expire(familyKey, record.expiresAt - Math.floor(Date.now() / 1000)); } async markTokenUsed(tokenId: string, usedAt: number): Promise<boolean> { const key = `refresh_token:${tokenId}`; // Atomic read-modify-write using Lua script const script = ` local record = redis.call('GET', KEYS[1]) if not record then return 0 -- Token not found end local data = cjson.decode(record) if data.usedAt then return -1 -- Already used end data.usedAt = ARGV[1] redis.call('SET', KEYS[1], cjson.encode(data)) return 1 -- Successfully marked `; const result = await this.redis.eval(script, 1, key, usedAt); return result === 1; } async invalidateTokenFamily(familyId: string): Promise<void> { const familyKey = `token_family:${familyId}`; // Get all tokens in the family const tokenIds = await this.redis.smembers(familyKey); // Delete all tokens const tokenKeys = tokenIds.map(id => `refresh_token:${id}`); if (tokenKeys.length > 0) { await this.redis.del(...tokenKeys); } // Delete the family set await this.redis.del(familyKey); // Also add family to a blocklist for any tokens still in flight const blocklistKey = `blocked_family:${familyId}`; await this.redis.setex(blocklistKey, 86400, 'blocked'); // 24 hour buffer } async isFamilyBlocked(familyId: string): Promise<boolean> { const blocklistKey = `blocked_family:${familyId}`; return await this.redis.exists(blocklistKey) === 1; } async getRefreshToken(tokenId: string): Promise<RefreshTokenRecord | null> { const key = `refresh_token:${tokenId}`; const record = await this.redis.get(key); if (!record) return null; const parsed = JSON.parse(record) as RefreshTokenRecord; // Also check if family is blocked if (await this.isFamilyBlocked(parsed.familyId)) { return null; // Family was invalidated } return parsed; }}Even after deleting all known tokens, maintain a family blocklist for 24 hours. This catches any tokens that were issued but not yet stored (race conditions) or that exist in client-side storage. The blocklist is checked during validation as a final safeguard.
When token reuse is detected, different responses are appropriate depending on your security posture and the nature of your application.
| Strategy | Action | When to Use |
|---|---|---|
| Silent Invalidation | Invalidate family, return standard error | Low-friction apps where false positives are costly |
| Session Termination | Invalidate family + all user sessions | Medium security; assumes any reuse indicates compromise |
| Account Lockout | Suspend account pending verification | High security; requires human review |
| Forced MFA | Require step-up authentication to continue | Balanced approach; verify user is legitimate |
| Password Reset | Require password change on next login | Assumes credential compromise |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
enum ReuseResponseStrategy { SILENT = 'silent', // Just invalidate, don't alert user NOTIFY = 'notify', // Invalidate and notify user LOCKOUT = 'lockout', // Lock account pending review FORCE_MFA = 'force_mfa', // Require additional verification RESET = 'reset', // Require password reset} class TokenReuseHandler { constructor( private tokenStore: TokenFamilyStore, private notificationService: NotificationService, private securityAlertService: SecurityAlertService, private mfaService: MFAService ) {} async handleReuse( record: RefreshTokenRecord, strategy: ReuseResponseStrategy ): Promise<void> { // Step 1: Always invalidate the entire token family await this.tokenStore.invalidateTokenFamily(record.familyId); // Step 2: Log the security event await this.logSecurityEvent(record); // Step 3: Execute strategy-specific actions switch (strategy) { case ReuseResponseStrategy.SILENT: // Nothing more to do break; case ReuseResponseStrategy.NOTIFY: await this.notifyUser(record.userId); break; case ReuseResponseStrategy.LOCKOUT: await this.lockAccount(record.userId); await this.notifySecurityTeam(record); break; case ReuseResponseStrategy.FORCE_MFA: await this.requireMFAOnNextLogin(record.userId); await this.notifyUser(record.userId); break; case ReuseResponseStrategy.RESET: await this.forcePasswordReset(record.userId); await this.notifyUser(record.userId); break; } } private async notifyUser(userId: string): Promise<void> { const user = await this.userService.findById(userId); await this.notificationService.send({ userId, channel: 'email', template: 'security_alert', data: { subject: 'Security Alert: Unusual Account Activity', message: ` We detected unusual activity on your account. For your protection, we've signed you out of all devices. If this wasn't you, please change your password immediately. `, timestamp: new Date().toISOString(), }, }); } private async lockAccount(userId: string): Promise<void> { await this.userService.update(userId, { status: 'locked', lockedReason: 'Suspicious token activity detected', lockedAt: new Date(), }); // Invalidate ALL sessions for this user, not just this family await this.tokenStore.invalidateAllUserTokens(userId); } private async requireMFAOnNextLogin(userId: string): Promise<void> { await this.mfaService.setRequiredOnNextLogin(userId, { reason: 'Token reuse detected', expiresIn: 86400, // Must complete MFA within 24 hours }); } private async forcePasswordReset(userId: string): Promise<void> { await this.userService.update(userId, { requiresPasswordReset: true, passwordResetReason: 'Security measure after suspicious activity', }); await this.tokenStore.invalidateAllUserTokens(userId); }}Aggressive responses (lockout, password reset) protect against attacks but can frustrate legitimate users who trigger false positives. Consider your user base and threat model. A consumer app might use 'notify' while a banking app uses 'lockout'.
One of the trickiest aspects of rotation is handling legitimate concurrent refresh attempts. Consider a mobile app with multiple API calls in flight when the access token expires:
Without proper handling, this triggers false-positive reuse detection and breaks the legitimate session.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// Strategy 1: Client-side request queuing// Only one refresh request at a time; others wait class ClientTokenManager { private refreshPromise: Promise<TokenPair> | null = null; async refreshTokens(): Promise<TokenPair> { // If a refresh is already in progress, wait for it if (this.refreshPromise) { return this.refreshPromise; } // Start the refresh this.refreshPromise = this.doRefresh(); try { return await this.refreshPromise; } finally { this.refreshPromise = null; } } private async doRefresh(): Promise<TokenPair> { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', }); if (!response.ok) { throw new RefreshError('Refresh failed'); } return response.json(); }} // Strategy 2: Server-side grace period// Allow brief window where both old and new tokens work class GracefulRotationService { private readonly GRACE_PERIOD_SECONDS = 30; async rotateWithGracePeriod(oldToken: string): Promise<TokenPair> { const payload = this.verifyRefreshToken(oldToken); const record = await this.tokenStore.getRefreshToken(payload.jti!); if (!record) { throw new InvalidTokenError('Token not found'); } // Check if within grace period of a recently used token if (record.usedAt) { const secondsSinceUse = (Date.now() - record.usedAt) / 1000; if (secondsSinceUse <= this.GRACE_PERIOD_SECONDS) { // Within grace period - return the already-created tokens if (record.replacedBy) { const newRecord = await this.tokenStore.getRefreshToken(record.replacedBy); if (newRecord) { // Return the same token pair that was created return { accessToken: await this.regenerateAccessToken(record.userId), refreshToken: await this.getTokenFromRecord(newRecord), }; } } } // Outside grace period - this is reuse await this.handleTokenReuse(record); throw new TokenReuseError('Token already used'); } // Normal rotation flow... return this.performRotation(record); }} // Strategy 3: Idempotency key// Client includes idempotency key; server deduplicates class IdempotentRotationService { async rotate(oldToken: string, idempotencyKey: string): Promise<TokenPair> { // Check if we've already processed this exact request const cachedResult = await this.redis.get(`idem:${idempotencyKey}`); if (cachedResult) { return JSON.parse(cachedResult); } // Acquire lock on the token const lockKey = `lock:refresh:${this.getTokenId(oldToken)}`; const lockAcquired = await this.redis.set(lockKey, '1', 'NX', 'EX', 30); if (!lockAcquired) { // Another request is processing this token // Wait and check for cached result await this.sleep(100); return this.rotate(oldToken, idempotencyKey); } try { // Perform rotation const result = await this.performRotation(oldToken); // Cache result with idempotency key await this.redis.setex( `idem:${idempotencyKey}`, 60, // Cache for 60 seconds JSON.stringify(result) ); return result; } finally { await this.redis.del(lockKey); } }}The best implementations combine client-side queuing (prevents most concurrent requests) with a short server-side grace period (handles edge cases). This provides resilience without compromising security significantly.
Token rotation requires server-side storage. The choice between Redis, databases, or hybrid approaches significantly impacts performance, durability, and operational complexity.
| Storage | Performance | Durability | Atomicity | Best For |
|---|---|---|---|---|
| Redis | Excellent (<1ms) | Configurable (AOF/RDB) | Supported (Lua scripts) | High-throughput, acceptable data loss |
| PostgreSQL | Good (5-20ms) | Excellent (ACID) | Native transactions | Audit trails, compliance requirements |
| MongoDB | Good (2-10ms) | Good (replica sets) | Document-level | Flexible schema, moderate scale |
| DynamoDB | Excellent (<5ms) | Excellent (managed) | Conditional writes | AWS-native, auto-scaling |
| Hybrid (Redis + DB) | Excellent (cached) | Excellent (DB backup) | Complex | Best of both worlds |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// Hybrid approach: Redis for speed, DB for durability class HybridTokenStore { constructor( private redis: RedisClient, private db: Database ) {} async storeRefreshToken(record: RefreshTokenRecord): Promise<void> { // Write to Redis (primary, fast reads) await this.redis.setex( `refresh:${record.tokenId}`, record.expiresAt - Math.floor(Date.now() / 1000), JSON.stringify(record) ); // Async write to database (durability, audit trail) // Don't await - background insert this.db.refreshTokens.insert(record).catch(err => { console.error('Failed to persist token to DB', err); // Token still works from Redis - log for monitoring }); } async getRefreshToken(tokenId: string): Promise<RefreshTokenRecord | null> { // Try Redis first const cached = await this.redis.get(`refresh:${tokenId}`); if (cached) { return JSON.parse(cached); } // Fallback to database (after Redis restart, etc.) const record = await this.db.refreshTokens.findById(tokenId); if (record && record.expiresAt > Math.floor(Date.now() / 1000)) { // Populate Redis cache for next time await this.redis.setex( `refresh:${tokenId}`, record.expiresAt - Math.floor(Date.now() / 1000), JSON.stringify(record) ); return record; } return null; } async markTokenUsed(tokenId: string, usedAt: number): Promise<boolean> { // Atomic mark in Redis (source of truth for real-time) const script = ` local data = redis.call('GET', KEYS[1]) if not data then return 0 end local record = cjson.decode(data) if record.usedAt then return -1 end record.usedAt = ARGV[1] redis.call('SET', KEYS[1], cjson.encode(record)) return 1 `; const result = await this.redis.eval(script, 1, `refresh:${tokenId}`, usedAt); if (result === 1) { // Update database async this.db.refreshTokens.update(tokenId, { usedAt }).catch(console.error); return true; } return result !== 0; // 0 = not found, -1 = already used } async invalidateTokenFamily(familyId: string): Promise<void> { // Get all tokens from DB (complete record) const tokens = await this.db.refreshTokens.findByFamily(familyId); // Delete from Redis const redisKeys = tokens.map(t => `refresh:${t.tokenId}`); if (redisKeys.length > 0) { await this.redis.del(...redisKeys); } // Mark invalidated in DB (for audit, not deletion) await this.db.refreshTokens.updateMany( { familyId }, { invalidatedAt: new Date(), invalidatedReason: 'family_invalidation' } ); // Block the family await this.redis.setex(`blocked_family:${familyId}`, 86400, '1'); }}Regulations like PCI-DSS, HIPAA, or SOC2 may require durable audit trails of authentication events. In these cases, you must persist token records to a database, even if Redis handles the hot path. Design for both performance AND compliance.
In microservices architectures with multiple instances, rotation introduces coordination challenges. A refresh request might hit different instances, each needing to atomically mark tokens and generate replacements.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Pattern 1: Centralized token service// All rotation goes through a dedicated microservice class TokenService { // Deployed as single logical service (can have replicas) // Uses Redis/DB as source of truth // All other services call this for token operations @rateLimit({ max: 100, window: '1m' }) async rotate(request: RotateRequest): Promise<TokenPair> { // Centralized logic, single point of coordination }} // Pattern 2: Distributed locking// Any instance can handle rotation with distributed lock class DistributedRotationService { async rotate(oldToken: string): Promise<TokenPair> { const tokenId = this.extractTokenId(oldToken); const lockKey = `rotation_lock:${tokenId}`; // Acquire distributed lock const lock = await this.redlock.acquire([lockKey], 5000); try { // Only holder of lock proceeds with rotation return await this.performRotation(oldToken); } finally { await lock.release(); } }} // Pattern 3: Optimistic locking with conflict resolution// No locks, but handle conflicts gracefully class OptimisticRotationService { async rotate(oldToken: string): Promise<TokenPair> { const payload = this.verify(oldToken); // Try to atomically mark as used (CAS operation) const result = await this.tokenStore.compareAndSetUsed( payload.jti!, null, // Expected current value (null = unused) Date.now() // New value ); if (result === 'conflict') { // Someone else rotated - check if within grace period const record = await this.tokenStore.get(payload.jti!); if (record && this.withinGracePeriod(record)) { // Return the newly created tokens return this.getReplacementTokens(record); } throw new TokenReuseError(); } if (result === 'not_found') { throw new InvalidTokenError(); } // We won the race - create new tokens return this.createNewTokenPair(payload); }}For most systems, a dedicated token/auth service is simpler than distributed coordination. The refresh endpoint isn't called frequently enough to be a bottleneck. Centralize rotation logic, distribute token validation.
Refresh token rotation transforms static, high-value credentials into moving targets that enable theft detection. Let's consolidate the essential knowledge:
What's Next:
Now that you understand rotation mechanics, the next and final page covers JWT Security Considerations. We'll explore common vulnerabilities, attack patterns, secure storage practices, and defense-in-depth strategies to protect your JWT implementation from real-world threats.
You now have comprehensive knowledge of refresh token rotation. You understand why rotation is essential, how to implement one-time-use tokens with family tracking, handle concurrent requests, and choose appropriate storage strategies. This knowledge is critical for building secure, production-grade authentication systems. Next, we'll cover JWT security considerations.