The Exact Error
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-01-15T10:00:00Z. Current time: 2024-01-15T11:00:00Z
Or:
ClaimJwtException: JWT claim validation failed
JsonWebTokenError: jwt expired
jwt.exceptions.ExpiredSignatureError: Signature has expired
Quick summary: The JWT's
expclaim has passed, or another claim likenbforiatis invalid. Either the token genuinely expired, there's a milliseconds-vs-seconds bug in the issuer, or there's clock skew between signing and verification services.
Why This Error Happens
JWT timestamps (exp, iat, nbf) are Unix timestamps measured in seconds since January 1, 1970 (RFC 7519).
Four root causes:
1. Token genuinely expired ? The server time has passed the exp value.
2. Milliseconds vs seconds bug ? Date.now() returns milliseconds (13 digits), but JWT expects seconds (10 digits).
3. Clock skew ? The signing and verifying servers have different system clocks.
4. Wrong timezone handling ? Creating expiry timestamps from local timezone date objects instead of UTC Unix seconds.
Step-by-Step Diagnosis
Step 1 ? Decode the JWT and read the exp claim
const token = "eyJhbGci...";
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('exp:', payload.exp);
console.log('now (seconds):', Math.floor(Date.now() / 1000));
console.log('exp readable:', new Date(payload.exp * 1000).toISOString());
If exp is a 13-digit number, you have the milliseconds bug.
Step 2 ? Check if exp is seconds or milliseconds
const exp = payload.exp;
if (exp > 9999999999) {
console.error('BUG: exp is in milliseconds, not seconds');
} else {
console.log('Already expired:', exp < Math.floor(Date.now() / 1000));
}
Step 3 ? Measure clock skew between services
# On the signing server:
date +%s
# On the verification server:
date +%s
# Difference greater than 60 seconds = clock skew problem
Solutions
Solution 1 ? Fix milliseconds to seconds
// WRONG:
const payload = { exp: Date.now() + 3600000 }; // milliseconds!
// RIGHT ? use expiresIn option:
const token = jwt.sign(payload, secret, { expiresIn: '1h' });
// RIGHT ? manual seconds:
const payload = {
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
};
Solution 2 ? Add clock tolerance to the verifier
// jsonwebtoken (Node.js):
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
clockTolerance: 60
});
# PyJWT:
decoded = jwt.decode(token, secret, algorithms=["HS256"], leeway=60)
Solution 3 ? Handle token refresh for expired tokens
async function fetchWithAuth(url) {
let token = localStorage.getItem('access_token');
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
if (response.status === 401) {
token = await refreshAccessToken();
localStorage.setItem('access_token', token);
return fetch(url, { headers: { Authorization: `Bearer ${token}` } });
}
return response;
}
Quick Reference
| Symptom | Likely Cause | Fix |
|---|---|---|
| Token expires immediately | exp set in milliseconds | Divide by 1000: Math.floor(Date.now()/1000) |
| Works in dev, fails in prod | Different server clocks | Add clockTolerance to verifier |
| iat-in-the-future error | Clock skew between services | Enable NTP, add leeway |
| exp is 13 digits | Milliseconds bug | Correct to 10-digit Unix seconds |
Prevent This Error in the Future
1. Always use the library's expiresIn option rather than setting exp manually.
2. Set clockTolerance: 60 as a baseline in all verifiers.
3. Add a startup check that logs token claim values and timestamps.
Use ToolNinja to Debug Faster
The Timestamp Converter lets you instantly convert between Unix timestamps and human-readable dates. Paste the exp value from your JWT payload and see if it's in seconds or milliseconds, and when the token actually expires.
?? Timestamp Converter ? toolninja.io/tools/timestamp-converter