JWT Tokens
Token Expiration
Read aloud is unavailable in this browser.
The Expiration Dilemma
Token expiration is where security and user experience collide. Set tokens to expire too quickly, and users face constant re-authentication friction. Set them too long, and compromised tokens remain valid for extended periods, expanding your attack surface exponentially.
This isn't merely a technical configuration—it's a risk management decision that requires understanding your threat model, user expectations, and the sensitivity of protected resources. A banking application and a social media platform require fundamentally different expiration strategies.
This page equips you to make informed expiration decisions. We'll explore the mathematics of token exposure windows, the psychology of user tolerance for authentication friction, and the architectural patterns that allow short-lived tokens without sacrificing user experience.
By the end of this page, you will understand the security implications of token lifetime, know how to calculate appropriate expiration times for different scenarios, implement sliding expiration patterns, and design systems that balance security with seamless user experience.
Why Expiration Matters
JWTs are bearer tokens—possession alone grants access. Unlike session-based authentication where the server can invalidate sessions instantly, JWTs remain valid until they expire unless you implement additional revocation infrastructure.
This fundamental property makes expiration your primary defense against token compromise. Consider the exposure window:
Exposure Window = Token Lifetime - Time Until Compromise Example scenarios: Token Lifetime: 24 hoursToken Stolen: 1 hour after issuanceExposure Window: 23 hours (attacker has 23 hours of access) Token Lifetime: 15 minutesToken Stolen: 5 minutes after issuanceExposure Window: 10 minutes (much shorter attack window) Token Lifetime: 7 daysToken Stolen: 30 minutes after issuanceExposure Window: ~6 days 23 hours (extended attack window)The Statelessness Trade-off:
JWTs achieve statelessness by embedding all necessary information in the token itself. This provides tremendous scalability benefits:
- No centralized session store
- Any service can validate tokens independently
- Horizontal scaling is trivial
- No cache warming or session replication needed
But statelessness comes at a cost: instant revocation is impossible without reintroducing state. This is why expiration is so critical—it's the built-in revocation mechanism.
| Attack Vector | How Tokens Get Stolen | Mitigation Focus |
|---|---|---|
| XSS (Cross-Site Scripting) | Malicious script reads token from localStorage/memory | Never store in localStorage, use httpOnly cookies |
| Man-in-the-Middle | Attacker intercepts token in transit | Always use HTTPS, HSTS |
| Device Theft | Physical access to device with stored token | Short expiration, device binding |
| Log Exposure | Tokens logged in application/server logs | Never log tokens, use token IDs for correlation |
| Insider Threat | Malicious employee extracts tokens from systems | Audit logging, short expiration, principle of least privilege |
Many systems use a single, arbitrary token lifetime (often 24 hours or even longer) without thought. This is dangerous. Token lifetime should be derived from your threat model, not defaults. System design interviews expect you to articulate the reasoning behind your expiration choices.
Factors Influencing Expiration Time
Determining the right expiration time requires balancing multiple factors. No universal answer exists—the optimal lifetime depends on your specific context.
- •Data Sensitivity: Banking, healthcare, government systems need shorter tokens (minutes, not hours)
- •Privilege Level: Admin tokens should expire faster than regular user tokens
- •Attack Surface: Public-facing APIs have higher risk than internal-only services
- •Regulatory Requirements: PCI-DSS, HIPAA, GDPR may mandate session timeouts
- •Revocation Capability: Without revocation infrastructure, shorter tokens compensate
- •Multi-Tenancy Risk: Shared infrastructure increases blast radius of compromised tokens
- •Session Duration: How long do users typically stay active?
- •Re-authentication Friction: Password entry, MFA challenges are disruptive
- •Offline Usage: Mobile apps may need tokens valid for offline periods
- •Competitor Standards: Users expect consistency with similar products
- •Task Completion Time: Users shouldn't be logged out mid-transaction
- •Business Impact: Excessive friction causes user abandonment
Context-Specific Guidelines:
| Context | Access Token | Refresh Token | Rationale |
|---|---|---|---|
| Banking/Financial | 5-15 minutes | 1-4 hours | High-value transactions, regulatory compliance |
| Healthcare (HIPAA) | 15-30 minutes | 8 hours | PHI protection, compliance requirements |
| Enterprise SaaS | 15-60 minutes | 7-30 days | Balance security with productivity |
| Consumer Social | 1-24 hours | 30-90 days | User convenience priority, lower data sensitivity |
| E-commerce | 30-60 minutes | 7-14 days | Protect payment info, allow cart persistence |
| IoT/Device | Hours to days | Months | Unattended operation, limited human interaction |
| API/M2M | 5-15 minutes | N/A (use client credentials) | Frequent rotation, no human in loop |
Even within a single application, different token lifetimes may be appropriate. A 'remember me' session needs different handling than a sensitive action confirmation. Consider multiple token tiers with different expirations based on sensitivity.
Access Token Expiration Strategies
Access tokens are the primary credentials used to authenticate API requests. Their expiration strategy directly impacts both security posture and user experience.
Strategy 1: Fixed Short-Lived Tokens
The simplest approach: issue tokens with a fixed, short expiration (typically 15-60 minutes).
Pros:
- Limited exposure window for compromised tokens
- Simple to implement and reason about
- No additional infrastructure needed
Cons:
- Users re-authenticate frequently
- Disruptive for long-running sessions
- May not work for offline-first applications
1234567891011121314
// Fixed short-lived access tokenfunction createAccessToken(user: User): string { const now = Math.floor(Date.now() / 1000); return jwt.sign({ sub: user.id, iat: now, exp: now + 15 * 60, // Fixed 15-minute expiration type: 'access', }, accessTokenSecret);} // Client must handle 401 and prompt re-authentication// No automatic refresh mechanismStrategy 2: Long-Lived Tokens with Revocation
Issue longer-lived tokens (hours to days) but implement server-side revocation infrastructure.
Pros:
- Better user experience
- Can revoke instantly on security events
Cons:
- Requires centralized token store (Redis, database)
- Every request requires revocation check
- Partially defeats JWT statelessness benefits
12345678910111213141516171819202122232425262728293031323334
// Long-lived token with revocation checkfunction createAccessToken(user: User): string { const now = Math.floor(Date.now() / 1000); return jwt.sign({ sub: user.id, jti: crypto.randomUUID(), // Unique ID for revocation iat: now, exp: now + 24 * 60 * 60, // 24-hour expiration type: 'access', }, accessTokenSecret);} // Validation requires revocation checkasync function validateToken(token: string): Promise<JwtPayload> { const payload = jwt.verify(token, accessTokenSecret) as JwtPayload; // Check if token has been revoked const isRevoked = await redis.exists(`revoked:${payload.jti}`); if (isRevoked) { throw new Error('Token has been revoked'); } return payload;} // Revocation on security eventsasync function revokeToken(jti: string, expiresAt: number): Promise<void> { const ttl = expiresAt - Math.floor(Date.now() / 1000); if (ttl > 0) { // Store in Redis until original expiration await redis.setex(`revoked:${jti}`, ttl, 'revoked'); }}Strategy 3: Sliding Expiration
Extend token validity on each use. Common in web sessions but tricky with JWTs since the token itself can't be modified.
Implementation: Issue a new token on each request, or use refresh tokens to achieve similar effect.
Pros:
- Active users never experience timeout
- Natural session cleanup for inactive users
Cons:
- Token proliferation (multiple valid tokens for same session)
- Complexity in invalidating the sliding chain
Most production systems use short-lived access tokens (15-60 minutes) paired with longer-lived refresh tokens. This provides the security benefits of short expiration while maintaining user experience through transparent token renewal. We'll cover this pattern in detail.
The Refresh Token Pattern
The refresh token pattern is the industry-standard solution to the short-vs-long expiration dilemma. It separates the concerns:
- Access Token: Short-lived, used for API requests, can be stored in memory
- Refresh Token: Long-lived, used only to obtain new access tokens, stored securely
This separation provides:
- Limited exposure window for frequently-used access tokens
- Extended sessions without frequent re-authentication
- Revocation point through refresh token invalidation
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Token pair structureinterface TokenPair { accessToken: string; // Short-lived (15 min) refreshToken: string; // Long-lived (7 days) accessExpiresIn: number; refreshExpiresIn: number;} // Token generationclass TokenService { private readonly ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes private readonly REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7 days async createTokenPair(user: User): Promise<TokenPair> { const now = Math.floor(Date.now() / 1000); const refreshTokenId = crypto.randomUUID(); // Create short-lived access token const accessToken = jwt.sign({ sub: user.id, iat: now, exp: now + this.ACCESS_TOKEN_TTL, type: 'access', roles: user.roles, }, process.env.ACCESS_TOKEN_SECRET!); // Create long-lived refresh token const refreshToken = jwt.sign({ sub: user.id, jti: refreshTokenId, iat: now, exp: now + this.REFRESH_TOKEN_TTL, type: 'refresh', }, process.env.REFRESH_TOKEN_SECRET!); // Store refresh token for revocation capability await this.storeRefreshToken(user.id, refreshTokenId, now + this.REFRESH_TOKEN_TTL); return { accessToken, refreshToken, accessExpiresIn: this.ACCESS_TOKEN_TTL, refreshExpiresIn: this.REFRESH_TOKEN_TTL, }; } async refreshAccessToken(refreshToken: string): Promise<TokenPair> { // Verify refresh token const payload = jwt.verify( refreshToken, process.env.REFRESH_TOKEN_SECRET!, { algorithms: ['HS256'] } ) as JwtPayload; if (payload.type !== 'refresh') { throw new Error('Invalid token type'); } // Check if refresh token is still valid in store const isValid = await this.isRefreshTokenValid(payload.sub!, payload.jti!); if (!isValid) { throw new Error('Refresh token has been revoked'); } // Look up current user state (permissions may have changed) const user = await this.userRepository.findById(payload.sub!); if (!user || !user.isActive) { throw new Error('User not found or inactive'); } // Create new token pair (refresh token rotation) // Invalidate old refresh token await this.revokeRefreshToken(payload.jti!); return this.createTokenPair(user); } private async storeRefreshToken( userId: string, tokenId: string, expiresAt: number ): Promise<void> { const ttl = expiresAt - Math.floor(Date.now() / 1000); await redis.setex(`refresh:${tokenId}`, ttl, JSON.stringify({ userId, expiresAt, })); } private async isRefreshTokenValid( userId: string, tokenId: string ): Promise<boolean> { const stored = await redis.get(`refresh:${tokenId}`); if (!stored) return false; const data = JSON.parse(stored); return data.userId === userId; } private async revokeRefreshToken(tokenId: string): Promise<void> { await redis.del(`refresh:${tokenId}`); }}- •Short-lived: 5-60 minutes typical
- •Stateless: No server-side storage required
- •Contains claims: Roles, permissions for authorization
- •Storage: Memory, sessionStorage (NOT localStorage)
- •Used: With every API request
- •Long-lived: Hours to weeks
- •Stateful: Stored server-side for revocation
- •Minimal claims: Just sub, jti, exp
- •Storage: httpOnly, Secure cookie
- •Used: Only when access token expires
Access and refresh tokens should use different signing secrets. This prevents an access token from being used as a refresh token (or vice versa) if an attacker tries to swap token types. The 'type' claim provides additional defense but should not be the only safeguard.
Implementing Token Refresh Flow
The client-side refresh flow must handle token expiration gracefully without disrupting user experience. Here's a production-ready implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// Token management on the client side interface TokenState { accessToken: string | null; accessExpiresAt: number; // Unix timestamp isRefreshing: boolean; refreshPromise: Promise<void> | null;} class TokenManager { private state: TokenState = { accessToken: null, accessExpiresAt: 0, isRefreshing: false, refreshPromise: null, }; // Threshold to refresh before actual expiration private readonly REFRESH_THRESHOLD_SECONDS = 60; /** * Get valid access token, refreshing if necessary */ async getAccessToken(): Promise<string> { // If currently refreshing, wait for that to complete if (this.state.refreshPromise) { await this.state.refreshPromise; } // Check if token needs refresh const now = Math.floor(Date.now() / 1000); const needsRefresh = !this.state.accessToken || now >= this.state.accessExpiresAt - this.REFRESH_THRESHOLD_SECONDS; if (needsRefresh) { await this.refreshTokens(); } if (!this.state.accessToken) { throw new Error('No valid access token'); } return this.state.accessToken; } /** * Refresh tokens using the refresh token cookie */ private async refreshTokens(): Promise<void> { // Prevent concurrent refresh attempts if (this.state.isRefreshing) { return this.state.refreshPromise!; } this.state.isRefreshing = true; this.state.refreshPromise = this.doRefresh(); try { await this.state.refreshPromise; } finally { this.state.isRefreshing = false; this.state.refreshPromise = null; } } private async doRefresh(): Promise<void> { try { // Refresh token is sent automatically via httpOnly cookie const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', // Include cookies }); if (!response.ok) { if (response.status === 401) { // Refresh token invalid - user must re-authenticate this.handleSessionExpired(); return; } throw new Error('Token refresh failed'); } const data = await response.json(); // Store new access token in memory this.state.accessToken = data.accessToken; this.state.accessExpiresAt = Math.floor(Date.now() / 1000) + data.accessExpiresIn; // New refresh token is automatically set as httpOnly cookie by server } catch (error) { console.error('Token refresh error:', error); this.handleSessionExpired(); } } private handleSessionExpired(): void { this.state.accessToken = null; this.state.accessExpiresAt = 0; // Redirect to login or emit event for app to handle window.dispatchEvent(new CustomEvent('session:expired')); }} // HTTP client with automatic token managementclass AuthenticatedClient { private tokenManager = new TokenManager(); async fetch(url: string, options: RequestInit = {}): Promise<Response> { const accessToken = await this.tokenManager.getAccessToken(); const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}`, }, }); // Handle token expiration during request if (response.status === 401) { // Token expired between validation and use // Force refresh and retry once await this.tokenManager.refreshTokens(); const newToken = await this.tokenManager.getAccessToken(); return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${newToken}`, }, }); } return response; }}Always refresh tokens BEFORE they expire, not after. This prevents the 'race condition' where a token expires between the client checking validity and the server receiving the request. A 60-second threshold is a reasonable starting point.
Absolute vs Sliding Expiration
Two fundamental expiration models exist, often combined in sophisticated systems:
Absolute Expiration: Token expires at a fixed point in time, regardless of activity.
Sliding Expiration: Token validity extends with each use.
Both have valid use cases, and production systems often implement both.
| Aspect | Absolute | Sliding |
|---|---|---|
| Behavior | Fixed end time | Extends with each request |
| Maximum Session | Bounded by exp claim | Can extend indefinitely |
| Inactive Timeout | Not built-in | Natural behavior |
| Security | Guaranteed max exposure | Potential unlimited session |
| Use Case | Sensitive operations | Standard browsing sessions |
| Implementation | Standard exp claim | Requires re-issuing tokens |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Combined strategy: Absolute maximum with sliding activity interface TokenConfig { accessTokenTtl: number; // 15 minutes refreshTokenTtl: number; // 1 hour (sliding) absoluteMaxSession: number; // 8 hours (absolute hard limit)} class SessionService { private config: TokenConfig = { accessTokenTtl: 15 * 60, // 15 minutes refreshTokenTtl: 60 * 60, // 1 hour sliding absoluteMaxSession: 8 * 60 * 60, // 8 hour absolute max }; async createSession(user: User): Promise<TokenPair> { const now = Math.floor(Date.now() / 1000); const sessionStart = now; // Track when session began return this.createTokenPair(user, sessionStart); } async refreshSession(refreshToken: string): Promise<TokenPair> { const payload = jwt.verify(refreshToken, secret) as JwtPayload; const now = Math.floor(Date.now() / 1000); const sessionStart = payload.session_start as number; // Check absolute maximum session duration if (now - sessionStart >= this.config.absoluteMaxSession) { throw new AbsoluteSessionExpiredError( 'Maximum session duration exceeded - please re-authenticate' ); } // Calculate how much sliding time remains const absoluteEndTime = sessionStart + this.config.absoluteMaxSession; const remainingAbsoluteTime = absoluteEndTime - now; // Sliding expiration bounded by absolute max const slidingExpiry = Math.min( this.config.refreshTokenTtl, remainingAbsoluteTime ); // Refresh with sliding expiration (but never exceed absolute) const user = await this.userRepository.findById(payload.sub!); return this.createTokenPair(user, sessionStart, slidingExpiry); } private createTokenPair( user: User, sessionStart: number, refreshTtl?: number ): TokenPair { const now = Math.floor(Date.now() / 1000); const actualRefreshTtl = refreshTtl ?? this.config.refreshTokenTtl; const accessToken = jwt.sign({ sub: user.id, iat: now, exp: now + this.config.accessTokenTtl, session_start: sessionStart, // Carry forward session start type: 'access', }, accessSecret); const refreshToken = jwt.sign({ sub: user.id, jti: crypto.randomUUID(), iat: now, exp: now + actualRefreshTtl, // Sliding but bounded session_start: sessionStart, // Needed for absolute check type: 'refresh', }, refreshSecret); return { accessToken, refreshToken, accessExpiresIn: this.config.accessTokenTtl, refreshExpiresIn: actualRefreshTtl, }; }}Pure sliding expiration can result in sessions lasting indefinitely. An attacker who obtains credentials could maintain access forever if they keep the session active. Always implement an absolute maximum session duration (e.g., 8-24 hours) that forces re-authentication regardless of activity.
Expiration in Distributed Systems
Distributed systems introduce additional challenges for token expiration: clock drift between services, propagation delays, and the need for consistent validation across nodes.
Clock Drift and Tolerance
Different servers may have slightly different system clocks. Without synchronization, a token valid on one server may appear expired on another.
Solution: Use NTP on all servers and implement clock tolerance in validation.
12345678910111213141516171819202122232425262728
// Clock tolerance configurationconst CLOCK_TOLERANCE_SECONDS = 30; function validateExpiration(payload: JwtPayload): void { const now = Math.floor(Date.now() / 1000); // Check exp with tolerance if (payload.exp && now >= payload.exp + CLOCK_TOLERANCE_SECONDS) { throw new TokenExpiredError('Token has expired'); } // Check nbf with tolerance if (payload.nbf && now < payload.nbf - CLOCK_TOLERANCE_SECONDS) { throw new TokenNotYetValidError('Token not yet valid'); } // Check iat is reasonable (not in future, not too old) if (payload.iat) { if (payload.iat > now + CLOCK_TOLERANCE_SECONDS) { throw new InvalidTokenError('Token issued in the future'); } }} // Using jwt library with tolerancejwt.verify(token, secret, { clockTolerance: 30, // 30 second tolerance});Revocation Propagation Delay
When using centralized revocation (Redis, database), there's propagation delay between revocation and all nodes recognizing it.
Considerations:
- Use short TTLs on cache for revocation checks
- Accept a small window of continued access after revocation
- For critical revocations (security incidents), broadcast invalidation signals
- •Use NTP: Synchronize all server clocks to within milliseconds
- •Implement tolerance: 30-60 seconds covers normal drift
- •Short access tokens: Minimize the window where revocation matters
- •Eventual consistency: Accept that revocation isn't instant
- •Critical revocation path: Have a fast path for security emergencies
- •Log and audit: Track token usage to detect post-revocation access attempts
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.