JWT Tokens
Refresh Token Rotation
Read aloud is unavailable in this browser.
When the Backup Plan Gets Stolen
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.
The Refresh Token Threat Model
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:
- They're longer-lived (hours to weeks vs minutes)
- They can generate unlimited access tokens until expiration
- They're often stored persistently (unlike memory-only access tokens)
- Their theft may go unnoticed longer
| 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.
Rotation Mechanics
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:
- Client presents refresh token RT1 to get new tokens
- Server validates RT1, issues new access token + new refresh token RT2
- Server invalidates RT1 (it can never be used again)
- Client stores RT2, discards RT1
- Next refresh uses RT2, which produces RT3, and so on
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 Family Tracking
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:
- User logs in, receives RT1
- Attacker steals RT1
- Attacker uses RT1, receives RT2
- User tries to use RT1 (reuse detected)
- Without families: Only RT1 is invalid. Attacker still has RT2.
- With families: RT1.family is invalidated. RT2 is also dead.
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.
Handling Reuse Detection
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'.
Handling Concurrent Requests
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:
- Request A fails with 401 (token expired)
- Request B fails with 401 at the same time
- Both requests simultaneously try to refresh using the same refresh token
- One succeeds, the other sees the token as "already used"
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); } }}- •Pros: Simple, no server changes needed
- •Cons: Doesn't help with multiple browser tabs
- •Best for: Single-page applications
- •Pros: Handles all concurrent scenarios
- •Cons: Brief window of vulnerability
- •Best for: Multi-tab, multi-device scenarios
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.
Storage Strategies
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.
Rotation in Distributed Systems
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.
- •Race conditions: Multiple instances processing same token simultaneously
- •Network partitions: Partial visibility of invalidation state
- •Consistency: All instances must agree on token validity
- •Latency: Coordination adds overhead to refresh requests
- •Failure handling: What if instance crashes mid-rotation?
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.