JWT Expired Token Error — Causes and Fixes
Why you see jwt expired errors, how exp works, and how to debug expiry with a browser JWT decoder.
By Mariana Soto · Published 2026-07-02 · Updated 2026-08-22
The jwt expired error (or TokenExpiredError, JWTExpired, depending on your library) means the token's exp claim is in the past relative to the verifier's clock. Compliant libraries reject the token before your route handler or business logic runs. This is one of the most common JWT errors in production — and one of the easiest to diagnose when you know what to look for.
A real-world scenario
Imagine a support ticket: "The mobile app worked this morning, now every API call returns 401." Your API logs show:
TokenExpiredError: jwt expired
at jwtVerify (...)
exp: 1735686000, now: 1735689600
The token was valid when issued at 09:00 but expired at 09:15. The user's app cached the access token and kept sending it after exp. No amount of signature verification fixes an expired token — lifetime checks happen first in most libraries.
How exp works
exp is a NumericDate: Unix time in seconds (not milliseconds), defined in RFC 7519 §4.1.4. If the current Unix timestamp is greater than exp, the token must be rejected.
Example from Cyberway's weekly challenge:
{
"sub": "challenge-user",
"iss": "cyberway.dev",
"iat": 1700000000,
"exp": 1700003600,
"jti": "cw-challenge-1"
}
Here exp is 1700003600 — November 14, 2023 in UTC. Any verifier running after that instant rejects the token regardless of signature validity.
Clock skew can cause false expirations or brief windows where tokens appear valid when they should not. Production systems often allow a small leeway (30–60 seconds) when comparing exp and nbf, but do not disable expiry checks entirely.
The milliseconds bug
JavaScript's Date.now() returns milliseconds. A frequent bug is storing that value directly in exp:
// Wrong — exp is ~1000× too large or misinterpreted
const payload = { exp: Date.now() + 3600_000 };
// Correct — convert to seconds
const payload = { exp: Math.floor(Date.now() / 1000) + 3600 };
If your library expects seconds but receives milliseconds, behavior varies: some libraries treat the token as far-future valid; others fail parsing. Always confirm units in your issuer and verifier.
Quick check in Node:
const exp = 1700003600;
console.log(new Date(exp * 1000).toISOString()); // UTC human-readable
Debug with Cyberway
When you have a token and need to see expiry at a glance:
- Open the Cyberway JWT Decoder.
- Paste the token (it stays in your browser — nothing is sent to a server).
- Read the Expiry badge: expired, valid for X minutes, or missing
exp. - Inspect
iatandexptogether — a one-hour gap between them usually means a short-lived access token.
For hands-on practice, load the weekly challenge token — it is deliberately expired. Submit exp or expired in the challenge panel to unlock the expiry badge and security callouts.
Common fixes
Issue shorter-lived access tokens. Fifteen-minute access tokens plus refresh tokens limit exposure when a token leaks.
Implement refresh properly. When the API returns 401 with an expired error, exchange a refresh token for a new access token — do not silently retry the same JWT.
Align NTP on verifying servers. If exp checks fail intermittently, compare date output across API nodes. Skewed clocks cause confusing partial outages.
Log exp in human-readable UTC during incident response. Raw Unix seconds are error-prone under pressure.
Avoid multi-year lifetimes that hide missing rotation logic. Long exp values often mean "we never implemented refresh."
Catching expiry in code
With jose:
import { jwtVerify, errors } from "jose";
try {
await jwtVerify(token, secret, { algorithms: ["HS256"] });
} catch (err) {
if (err instanceof errors.JWTExpired) {
// Prompt client to refresh
return res.status(401).json({ code: "token_expired" });
}
throw err;
}
Handle expiry as a expected client flow, not only as an unexpected server error.
Refresh token interaction
Access token expiry is normal. Your client should:
- Detect 401 with
token_expired(or parseJWTExpiredserver-side). - Call the refresh endpoint with an HttpOnly refresh cookie or opaque refresh token.
- Store the new access token and retry the original request once.
If step 3 loops forever with the same expired JWT, the bug is client-side caching — not API auth configuration.
Monitoring recommendations
Track these metrics separately:
jwt_expired_total— client needs refresh; usually benign at low rates.jwt_invalid_signature_total— possible attack or key mismatch; investigate spikes.jwt_missing_total— client integration bug.
Alert on signature failures, not on steady expiry rates during peak logout hours.
Related tools
Paste the same token into the JWT Generator to mint a fresh token with a future exp and confirm your API accepts it — isolating expiry as the only variable.
Preventing repeat incidents
Document the expected access token TTL in your runbook (e.g. 15 minutes). When support sees jwt expired, the first question should be "when was the token issued?" not "did we rotate keys?" Add a staging alert when more than 5% of auth failures in five minutes are JWTExpired — that pattern often precedes a broken refresh deploy rather than an attack.
For the bigger picture, read the Complete JWT Guide and the JWT Challenge Week 1 walkthrough.
Try the Cyberway JWT Decoder or JWT Generator — both run entirely in your browser.
