JWT Expired: Every Real Cause and How to Fix It
TokenExpiredError: jwt expired looks like the simplest JWT failure there is — the clock ran out, get a new token. That's true for a lot of cases, but not all of them: the same error text also shows up when the token was never really "expired" in any meaningful sense, and the bug is somewhere else entirely — a comparison that mixes seconds and milliseconds, a verifier's clock that's a few minutes fast, or a refresh call that's been silently failing for hours. This walkthrough covers five real causes, how to tell them apart, and a live, prefilled JWT Verifier example for the ones that live in the token itself.
exp value as a human-readable timestamp before assuming anything. If exp is genuinely in the past, the token really is expired — get a new one. If exp looks fine and your app still rejects it, the bug is almost always in the code doing the comparison, not the token.
Quick reference
All five results below were computed against the exact exp/nbf logic JWT Verifier runs client-side, then verified live in the tool before publishing.
| Symptom | Most likely cause | Fix |
|---|---|---|
Expired, and exp really is in the past | The token is honestly past its lifetime | Re-authenticate or use a refresh token to get a new one |
| Expired within seconds of being issued | An intentionally short TTL plus real network/processing latency ate the whole window | Give the token enough lifetime to survive the trip it actually needs to make |
| Expired immediately, every single token, no exceptions | Your own check compares exp (seconds) against Date.now() (milliseconds) | Convert one side: Math.floor(Date.now()/1000) < payload.exp |
| Expired or "not yet valid" right at the edge, inconsistently | Clock skew between the issuing server and the verifying server | Sync clocks with NTP and add a small clock-tolerance buffer |
| Expired, but the user swears they "just logged in" | A background refresh call has been failing silently and the client keeps resending the same stale token | Surface refresh failures instead of swallowing them |
1. The token is honestly past its exp
This is the case the error message describes correctly: the token's exp claim is a real Unix timestamp in the past, and the library is doing exactly what it's supposed to do by rejecting it. It's worth starting here anyway, because it's the baseline every other cause in this piece gets compared against — if decoding the token shows an exp that's actually behind "now," there's no bug to chase, just a token that needs replacing.
jwt.verify(token, secret);
// TokenExpiredError: jwt expired at 2026-06-02T00:00:00.000Z
// (thrown correctly — the token's exp really was three months ago)
See it fail live
A token whose exp claim is 1780358400 — 2 Jun 2026, months before this piece was published:
exp: expired next to the human-readable date, and the signature itself checks out fine against dev-secret-2026. The token isn't broken. It's just old.
2. An ultra-short TTL, eaten by real-world latency
Some tokens are deliberately short-lived on purpose — a one-time magic-link login, a step-up-auth confirmation, a single-use download URL — and a 5-to-30-second TTL is a reasonable security choice for that use case. The bug shows up when the token has to survive a trip that takes longer than its TTL allows: an email delivery delay, a slow client-side redirect, a user who alt-tabbed away before clicking. The token was never wrong; the window was just narrower than the real-world path through it.
// Issued with a 5-second TTL for a one-time login link
const token = jwt.sign({ sub: userId, purpose: 'one_time_login' }, secret, { expiresIn: '5s' });
// ...email delivery, DNS, a slow client, a user who didn't click immediately...
jwt.verify(tokenFromEmailLink, secret);
// TokenExpiredError: jwt expired
See it fail live
A magic-link-style token minted with a 5-second exp window — by the time you're reading this, it's long past that window no matter when you click:
iat and exp are five seconds apart, which is the whole point: that gap needs to comfortably outlast the slowest real path a legitimate user takes through the flow, not just a happy-path benchmark.
3. Your own code compares seconds to milliseconds
RFC 7519 defines exp as seconds since the Unix epoch. JavaScript's Date.now() returns milliseconds. A hand-rolled expiration check that skips the unit conversion — if (payload.exp < Date.now()) — compares a roughly-10-digit number against a roughly-13-digit one, so exp reads as "expired" for every token, including ones minted seconds ago with an hour of real lifetime left. This is a bug in the verifying code, not the token, which is exactly why it's worth ruling out separately from cause 1: the fix is a division, not a new token.
// Buggy — exp is seconds, Date.now() is milliseconds
if (payload.exp < Date.now()) throw new Error('expired');
// payload.exp ≈ 1_820_409_308 (10 digits)
// Date.now() ≈ 1_788_873_308_412 (13 digits)
// The comparison is true immediately, for every token, forever.
// Fixed
if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired');
See it fail live — or rather, see it not fail
A healthy token with exp fifty years out (3368138082, 24 Sep 2076) — deliberately far out so this is a genuinely evergreen example rather than one that quietly stops demonstrating "valid" once a shorter horizon passes:
exp: valid, plenty of runway left. If your own application code rejects this exact token as expired, JWT Verifier just proved the token isn't the problem. Go check the units in your comparison.
4. Clock skew between the issuer and the verifier
JWT expiration is only as reliable as the two clocks it depends on agreeing with each other. If the server issuing tokens and the server (or service, or lambda) verifying them disagree by even a minute or two — a VM that never got NTP configured, a container that drifted after a long uptime, a CI runner with a stale clock — a token that was completely valid the instant it was signed can read as already expired the instant it's checked, or the mirror-image problem: a token can read as "not yet valid" if its nbf claim was set relative to the issuer's clock and the verifier's clock is running behind.
// Issued fine at the issuer's clock time
const token = jwt.sign({ sub: userId }, secret, { notBefore: 0, expiresIn: '1h' });
// Verified on a server whose clock is a few minutes behind the issuer's
jwt.verify(token, secret);
// NotBeforeError: jwt not active
// (the nbf the issuer stamped hasn't "arrived" yet on the verifier's clock)
// Mitigate with a tolerance buffer rather than assuming clocks agree exactly
jwt.verify(token, secret, { clockTolerance: 30 }); // seconds
See it fail live
A token with nbf set fifty years ahead of iat — exaggerated on purpose, and far enough out to stay a genuinely evergreen example rather than one that quietly flips to "valid" once a shorter horizon elapses. A real clock-skew gap is almost always seconds or minutes, not fifty years; the status JWT Verifier shows is identical either way, just the size of the gap differs:
nbf: not yet valid, next to an exp that's comfortably an hour past that nbf. Same underlying cause (clock disagreement) shows up as "expired" or "not yet valid" depending on which claim and which direction the skew runs — this example demonstrates the nbf side deliberately, since cause 1 above already covers the exp side.
5. A silently failed refresh keeps resending a stale token
Not every "expired" report is really about the token that got sent — sometimes it's about the token that should have been sent and wasn't. A common pattern: a client holds a short-lived access token and a longer-lived refresh token, and refreshes automatically in the background before the access token expires. If that background refresh call fails — a network blip, a refresh token that itself expired, an error swallowed by a bare catch {} with no logging or retry — the client keeps reusing the same now-expired access token on every request, because nothing ever told it the refresh didn't happen.
// The failure mode that hides this bug
async function getValidToken() {
try {
return await refreshAccessToken(); // fails — expired refresh token, network error, whatever
} catch {
return cachedAccessToken; // silently falls back to the token that's the whole problem
}
}
This one doesn't have a live "broken token" example, because the token itself is fine in isolation — the same genuinely-expired shape as cause 1, just arrived at via a different, invisible route. The tell is behavioral rather than something you can see by decoding a single token: the user reports being logged out "randomly," but the access token in the failing request is hours old, not seconds, and a fresh login fixes it instantly. Make refresh failures loud — log them, surface a re-auth prompt, don't fall back to a token you already know is stale — so this shows up as an obvious refresh-flow bug instead of a mysterious intermittent "expired" report.
iat is a common first guess when debugging, but iat is informational — most libraries don't reject a token for having an iat that looks odd, only for a past exp or a future nbf. If a token has a sane exp and still gets rejected, look at the five causes above before suspecting iat.
Debugging checklist
- Decode the token first and read
exp(andnbf, if present) as an actual human-readable timestamp, not a raw number. Confirm whether it's genuinely in the past before assuming anything else. - If
expis genuinely in the past by seconds rather than hours, check the TTL against the real-world latency the token has to survive, not a local happy-path test. - If
explooks completely healthy but your app still rejects it, check your comparison code for a missing/1000between seconds and milliseconds — this one bug rejects every token, every time. - If the failure is inconsistent and clusters right around the expiration boundary, compare the issuing server's clock and the verifying server's clock directly rather than assuming they agree.
- If the "expired" token turns out to be hours old rather than freshly issued, look at the refresh flow's error handling before looking at the token at all.
🔐 Paste your token and see exactly why it's expired
JWT Verifier decodes the header and claims, checks exp/nbf against the current time with human-readable timestamps, and verifies HS256 signatures entirely in your browser using the Web Crypto API — nothing is uploaded, and nothing is stored beyond your own device.