Error message
jwt expired
What it means
jsonwebtoken's TokenExpiredError: the token's exp claim (a Unix timestamp in seconds) is earlier than the verifier's current clock. The signature itself may be perfectly valid — the token has simply outlived the lifetime the issuer gave it. The error object carries expiredAt with the exact moment.
Why it happens
The token genuinely expired
commonShort-lived access tokens (5–60 minutes) are the norm; a client that caches a token and reuses it later, or a background job holding a token across a long run, will hit this exactly on schedule. The fix is a refresh flow, not a longer lifetime.
Clock skew between issuer and verifier
commonIf the verifying server's clock runs ahead of the issuer's, tokens "expire" early — sometimes the moment they are issued. Containers and VMs without NTP drift surprisingly fast; a skew of two minutes is enough to break sub-hour tokens intermittently.
exp set in milliseconds instead of seconds
occasionalThe spec (RFC 7519) defines exp in seconds. Code that writes Date.now() (milliseconds) into exp produces timestamps ~53,000 years in the future — while code converting the other way can produce timestamps in 1970, making every token instantly expired.
How to fix it
- 1.
Allow small skew explicitly
clockTolerance accepts seconds and applies to exp and nbf. Keep it small (30–60 s) — it exists to absorb clock drift, not to extend token lifetimes.
js jwt.verify(token, publicKey, { algorithms: ["ES256"], clockTolerance: 30, // seconds of tolerated skew }); - 2.
Inspect exp before blaming the clock
Decode the claims and print exp next to your current time. If exp looks like 1.7e12 you have a milliseconds bug; if it is minutes in the past, you need refresh logic.
js const { exp } = jwt.decode(token); console.log("exp:", new Date(exp * 1000).toISOString()); console.log("now:", new Date().toISOString()); - 3.
Sync clocks and add a refresh path
Run NTP (chrony/systemd-timesyncd) on verifying hosts, and implement token refresh on 401 responses client-side. Never ship ignoreExpiration: true to production — it turns every leaked token into a permanent credential.