Loading learning content...
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.
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:
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.
Determining the right expiration time requires balancing multiple factors. No universal answer exists—the optimal lifetime depends on your specific context.
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 tokens are the primary credentials used to authenticate API requests. Their expiration strategy directly impacts both security posture and user experience.
The simplest approach: issue tokens with a fixed, short expiration (typically 15-60 minutes).
Pros:
Cons:
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 mechanismIssue longer-lived tokens (hours to days) but implement server-side revocation infrastructure.
Pros:
Cons:
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'); }}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:
Cons:
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 is the industry-standard solution to the short-vs-long expiration dilemma. It separates the concerns:
This separation provides:
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}`); }}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.
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.
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.
Distributed systems introduce additional challenges for token expiration: clock drift between services, propagation delays, and the need for consistent validation across nodes.
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});When using centralized revocation (Redis, database), there's propagation delay between revocation and all nodes recognizing it.
Considerations:
Token expiration is a critical security control that requires careful consideration of your specific threat model and user needs. Let's consolidate the key insights:
What's Next:
Now that you understand token expiration strategies, the next page covers Refresh Token Rotation. We'll explore rotation strategies that limit the damage from stolen refresh tokens, implement one-time-use refresh tokens, and design token family tracking for detecting token theft.
You now have comprehensive knowledge of JWT token expiration strategies. You understand the security-UX tradeoffs, can calculate appropriate lifetimes for different scenarios, and know how to implement the access/refresh token pattern. This knowledge is essential for designing secure, user-friendly authentication systems. Next, we'll dive into refresh token rotation.