How to Handle JWT Token Expiration Gracefully
Master JWT token expiration handling with the exp claim, grace periods, and auto-refresh flows. Handle expired tokens gracefully.
In This Article
Understanding JWT Token Expiration
Every JWT should have an expiration time. Without one, a stolen token is valid forever — a massive security risk. The exp (expiration time) claim is one of the seven registered claims defined in RFC 7519 Section 4.1.4, and it's arguably the most important for security.
In this guide, we'll cover how expiration works, strategies for handling expired tokens gracefully, and how to build auto-refresh flows that keep users authenticated without compromising security. For a broader overview of JWT claims, see our What is JWT guide.
The exp Claim Explained
The exp claim contains a Unix timestamp (NumericDate) representing the expiration time of the token. After this time, the token MUST be rejected by any conforming implementation.
{
"sub": "user123",
"iat": 1720627200,
"exp": 1720630800
}
In this example, the token was issued at 1720627200 (July 10, 2024 12:00:00 UTC) and expires at 1720630800 (one hour later). Any verification after the expiration time should fail.
Other Time-Based Claims
JWT includes several time-related claims that work together:
iat(Issued At): When the token was creatednbf(Not Before): The earliest time the token is validexp(Expiration Time): When the token becomes invalid
A token is valid only when: nbf <= current_time < exp
Setting Appropriate Token Lifetimes
The right token lifetime balances security and user experience:
Short-Lived Access Tokens (15 minutes – 1 hour)
- Limit the window of opportunity for stolen tokens
- Reduce the impact of token leakage
- Force more frequent refresh, enabling permission updates
Medium-Lived Tokens (1 – 24 hours)
- Suitable for internal APIs or trusted environments
- Reduce refresh overhead for long user sessions
- Acceptable when combined with token revocation strategies
Long-Lived Tokens (days to weeks)
- Only appropriate for refresh tokens or API keys
- Require strong revocation mechanisms
- Should never be used as access tokens for web APIs
The general recommendation is 15 minutes for access tokens combined with refresh tokens for seamless session continuation.
Server-Side Expiration Validation
Always validate expiration on the server. Never trust the client to check token validity.
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
function verifyToken(token) {
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'],
clockTolerance: 30, // 30 seconds tolerance for clock skew
maxAge: '1h' // reject tokens older than 1 hour even if exp hasn't passed
});
return { valid: true, decoded };
} catch (err) {
if (err.name === 'TokenExpiredError') {
return { valid: false, reason: 'expired', expiredAt: err.expiredAt };
}
if (err.name === 'NotBeforeError') {
return { valid: false, reason: 'not_yet_valid', date: err.date };
}
return { valid: false, reason: 'invalid_token' };
}
}
// Express middleware
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const result = verifyToken(authHeader.split(' ')[1]);
if (!result.valid) {
if (result.reason === 'expired') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
req.user = result.decoded;
next();
}
Notice the clockTolerance option — this is crucial for handling clock skew between servers.
Handling Clock Skew
In distributed systems, server clocks are rarely perfectly synchronized. A token that appears expired on one server might still be valid on another. Here's how to handle it:
Server-Side Strategies
- Clock tolerance: Allow a small grace period (30–60 seconds) when checking expiration
- NTP synchronization: Ensure all servers sync to a reliable time source
- Issuer-side padding: Add a small buffer to the
expclaim when issuing tokens
Client-Side Strategy
Check expiration locally before sending requests, using a small buffer:
function isTokenExpiringSoon(token, bufferSeconds = 30) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp - bufferSeconds < now;
} catch {
return true; // treat unreadable tokens as expired
}
}
// Before making an API call
async function apiCall(url, options = {}) {
let token = getAccessToken();
if (isTokenExpiringSoon(token)) {
token = await refreshAccessToken();
}
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`
};
return fetch(url, options);
}
Graceful Expiration Handling on the Client
When a token does expire, the user experience shouldn't be jarring. Here's a strategy for handling expiration gracefully:
Proactive Refresh
Instead of waiting for the token to expire, refresh it proactively when it's close to expiring:
class TokenManager {
constructor() {
this.accessToken = null;
this.refreshTimer = null;
}
setToken(accessToken, expiresIn) {
this.accessToken = accessToken;
// Refresh 60 seconds before expiration
const refreshIn = (expiresIn - 60) * 1000;
this.scheduleRefresh(refreshIn);
}
scheduleRefresh(delay) {
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(async () => {
try {
const data = await this.doRefresh();
this.setToken(data.accessToken, data.expiresIn);
} catch (err) {
console.error('Token refresh failed:', err);
// Redirect to login
window.location.href = '/login';
}
}, delay);
}
async doRefresh() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include' // send refresh cookie
});
if (!response.ok) throw new Error('Refresh failed');
return response.json();
}
getToken() {
return this.accessToken;
}
}
const tokenManager = new TokenManager();
Retry on 401
Combine proactive refresh with a 401 retry mechanism (as shown in our refresh token guide). This ensures that even if proactive refresh fails, the client gets a second chance.
Grace Period Strategy
A grace period allows a recently expired token to still be accepted for a brief window. This is useful when:
- A request was in-flight when the token expired
- Network latency causes the token to arrive at the server after expiration
- Clock skew makes exact-time expiration unreliable
Implementation
const GRACE_PERIOD = 30; // 30 seconds
function verifyWithGracePeriod(token, secret) {
try {
// First try standard verification
return jwt.verify(token, secret, { algorithms: ['HS256'] });
} catch (err) {
if (err.name === 'TokenExpiredError') {
const expiredSecondsAgo = Math.floor(Date.now() / 1000) - err.expiredAt.getTime() / 1000;
if (expiredSecondsAgo <= GRACE_PERIOD) {
// Token expired within grace period — allow but flag for refresh
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
ignoreExpiration: true
});
decoded._gracePeriodUsed = true;
return decoded;
}
}
throw err;
}
}
Use grace periods sparingly — they expand the attack window. A 30-second grace period is usually sufficient.
Short-Lived vs Long-Lived Token Strategy
The most secure and practical approach combines short-lived access tokens with long-lived refresh tokens:
| Aspect | Access Token | Refresh Token |
|--------|-------------|---------------|
| Lifetime | 15 minutes | 7–30 days |
| On expiration | Silently refresh using refresh token | Force re-authentication |
| Storage | Memory (JavaScript variable) | HttpOnly secure cookie |
| Rotation | Not rotated | Rotated on each use |
This strategy ensures that even if an access token is compromised, it's only valid for 15 minutes. The refresh token provides seamless session continuity while being stored more securely. For implementation details, see our refresh token rotation guide and security best practices.
Conclusion
Token expiration is not just a security feature — it's a design decision that affects your entire authentication architecture. Use short-lived access tokens with the exp claim, handle clock skew with tolerance, refresh proactively on the client, and implement grace periods for edge cases. Use our JWT decoder to check the expiration claims in your tokens and verify they follow these best practices.
Need to decode a JWT token? Try our free JWT decoder tool — no sign-up required, runs entirely in your browser.