A JWT expired error with an exp that looks right almost always comes from a time mismatch, not a bad token. The usual causes are comparing seconds to milliseconds, a server clock that is ahead, a timezone bug when the token was created, or a library option set in the wrong unit. Decode the token, put exp and the verifier's current time in the same unit, and the cause shows up within a minute.
How a JWT expired check works
A JWT's exp claim is a NumericDate: seconds since the Unix epoch, in UTC. RFC 7519 defines it as "the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds."
The rule is strict. The current time "MUST be before" exp, so a token is rejected at the exact second it expires, not one second after. The verifier uses its own clock, not the issuer's.
RFC 7519 says implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew." Most libraries expose that leeway as an option, and it is off by default.
Quick diagnosis: match the symptom to the cause
Each cause leaves a different fingerprint, so start with what you actually see.
| What you see | Most likely cause | Where to look |
|---|---|---|
| Every token reads as expired the moment it's created | Seconds compared to milliseconds | Your own expiry check in the client |
| Token expires early or late by a whole number of hours | Timezone bug when exp was built | Token creation code (often Python) |
| Token lives a fraction of a second | Lifetime set in milliseconds by mistake | expiresIn value |
| Token never seems to expire | exp written in milliseconds | Token creation code |
| Fails on one server, works on another | Clock skew between machines | System time on each server |
| Error says "not active" or "not yet valid" | Verifier clock is behind, or nbf in the future | nbf claim and server time |
Paste the token into a decoder and read iat and exp as dates. If exp minus iat isn't the lifetime you intended, the bug is on the signing side. Otherwise it's in the check.
Cause 1: comparing seconds to milliseconds
The most common JWT expired bug in front-end code is a unit mismatch. MDN's Date.now() page says it returns a timestamp "in milliseconds," while exp is in seconds. A millisecond count is always far larger than a second count, so this check marks every token as expired:
// Wrong: Date.now() is milliseconds, exp is seconds
const expired = Date.now() > payload.exp;
// Right: compare in the same unit
const expired = Date.now() >= payload.exp * 1000;
The reverse mistake hides itself. Write exp in milliseconds when you sign, and the verifier reads it as seconds, putting expiry thousands of years away. The token works, so nobody notices. The node-jsonwebtoken README shows the right way to set it by hand: exp: Math.floor(Date.now() / 1000) + (60 * 60).
Cause 2: expiresIn set in the wrong unit
node-jsonwebtoken's expiresIn option looks simple, but its units depend on the type you pass. The README says "A numeric value is interpreted as a seconds count," and strings like "2 days", "10h" or "7d" are parsed as time spans.
The trap is a string with no unit. For those, the README says "milliseconds unit is used by default." So expiresIn: "120" gives a token that lives 120 milliseconds, while expiresIn: 120 gives two minutes. Environment variables trip this all the time, since they are always strings. Wrap the value in Number(), or always include a unit, such as "10h".
Cause 3: a timezone bug when the token was created
A token that expires hours too early or too late usually had exp built from local time instead of UTC. The JWT itself has no timezone, so the error is baked in when the number is calculated.
Python is the classic case. The Python datetime docs say "Naive datetime instances are assumed to represent local time" when you call .timestamp(). datetime.utcnow() returns exactly such a naive object, so on a server whose local time isn't UTC, its timestamp is off by the UTC offset. Python deprecated utcnow() in version 3.12 for this reason.
Use an aware datetime instead. The PyJWT docs note that exp "will be compared to the current UTC time" and use datetime.datetime.now(tz=timezone.utc) in their examples:
from datetime import datetime, timedelta, timezone
payload = {"exp": datetime.now(timezone.utc) + timedelta(hours=1)}
Cause 4: clock skew between servers
Clock skew means the issuer and the verifier disagree about the current time. If the verifier's clock runs ahead, a fresh token looks closer to expiry than it is. Short-lived tokens can then fail as soon as they arrive.
The real fix is time sync on every machine involved. Twilio's error 20155 page lists "clock drift" on the token-generating server as a likely cause, with the fix to "Correct the server's system time." Containers, CI runners and VMs that were paused or restored are the usual suspects.
While you fix the clock, add a small leeway:
- node-jsonwebtoken:
clockToleranceis the "number of seconds to tolerate when checking the nbf and exp claims," for examplejwt.verify(token, key, { clockTolerance: 60 }). - PyJWT:
jwt.decode(token, key, leeway=5, algorithms=["HS256"]), or atimedelta.
My take: a minute of leeway covers normal drift. If you need more, the clock is broken and leeway is hiding it. Big values also cost you security: node-jsonwebtoken issue 1021 reports that an extreme clockTolerance can let expired tokens pass, and the reporter suggests capping it around 300 seconds.
Cause 5: "not yet valid" errors from nbf
A token can also be rejected because it's too new. RFC 7519 says the current time "MUST be after or equal to" the nbf (not before) claim. If the verifier's clock is behind the issuer's, a token signed a second ago fails.
The error text is different, which helps. node-jsonwebtoken throws NotBeforeError with the message jwt not active, while a real expiry gives TokenExpiredError with jwt expired. Twilio's docs list an nbf "in the future relative to current time" as a cause of its "not yet valid" error. The same leeway setting covers both claims.
How to confirm the fix
Check the token and the clock separately, so you know which side was wrong.
- Decode the token and read
iat,nbfandexp. A decoder such as the JWT decoder shows those times and an expired or valid flag. - Confirm that exp minus iat equals the lifetime you meant to set.
- Print the current Unix time in seconds on the verifying server, for example
Math.floor(Date.now() / 1000)in Node. - Compare. If exp is in the future there, look for a unit bug in your own check. If not, look at the clock or the signing code.
- In node-jsonwebtoken, log
err.expiredAtfrom theTokenExpiredError. It tells you the moment the library thinks the token died.
One limitation of any browser decoder: its expired flag uses your laptop's clock, not your server's. A token can show as valid there and still fail on a server with a skewed clock.
A decoder also never checks the signature, so a readable token isn't a trusted one. JWT decode vs verify explains the difference, and how to decode a JWT safely covers what to paste where. For why anyone can read these claims at all, see decoding a JWT without the secret.
FAQ
Is the JWT exp claim in seconds or milliseconds?
Seconds. RFC 7519 defines exp as a NumericDate, the number of seconds since 1970-01-01T00:00:00Z UTC. JavaScript's Date.now() returns milliseconds, so divide it by 1000 before you compare.
Why does my token work on one server but show jwt expired on another?
The two servers almost certainly disagree about the current time. Fix the clock on the server that is wrong, and add a small leeway, such as clockTolerance in node-jsonwebtoken or leeway in PyJWT.
How much clock skew leeway should I allow?
Keep it small. RFC 7519 says leeway is usually no more than a few minutes. A huge value defeats expiry, and one node-jsonwebtoken issue reports that an extreme clockTolerance can let expired tokens pass.
What is the difference between jwt expired and jwt not active?
In node-jsonwebtoken, jwt expired (TokenExpiredError) means the current time is past exp. jwt not active (NotBeforeError) means the current time is still before nbf, which usually points to a clock that is behind.