JWT Expired: When the Token Is Fine and the Clock Is Not
Tokens rejected as expired seconds after issue, or accepted long after they should have died. Both are the same bug seen from opposite ends.
The short answer
JsonWebTokenError: jwt expired
TokenExpiredError: jwt expired at 2026-08-27T08:40:12.000Z
Before you touch the token logic, compare the two clocks:
# on the issuing host and the validating host
date -u +%s
# how far off is this machine from real time
chronyc tracking | grep "System time"
timedatectl status | grep -E "System clock|NTP service"
Then decode the token and read the claims as numbers, not as formatted dates:
echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iat, nbf, exp, now: now|floor}'
If exp is in the future by your reckoning and the validator says expired, the validator's clock is ahead. If iat is in the future from the validator's point of view, the issuer's clock is ahead and you will get nbf failures instead.
Tested on Node 22.14, jsonwebtoken 9.x, chrony 4.5.
The three claims that matter
| Claim | Name | Meaning |
|---|---|---|
iat |
Issued at | When the token was created |
nbf |
Not before | Earliest moment it is valid |
exp |
Expiry | Latest moment it is valid |
All three are seconds since the Unix epoch, not milliseconds. Every JWT library expects seconds. JavaScript's Date.now() returns milliseconds.
// wrong, expires in the year 56000 or fails validation depending on the library
{ exp: Date.now() + 3600000 }
// right
{ exp: Math.floor(Date.now() / 1000) + 3600 }
This mistake produces a token that never expires, which is a security problem rather than an availability one, so nobody notices until an audit.
Cause one: genuine clock skew between services
Validation is arithmetic against the local clock. Two services whose clocks differ by thirty seconds disagree about whether a token issued four seconds ago is valid.
The symptom is intermittent and load correlated in a way that misleads people. If one host in a pool has drifted, only requests routed to that host fail. You get roughly one in N failures, no pattern in the payload, and it looks like flakiness.
# check every host in the pool, not just the one you are on
for h in api-1 api-2 api-3; do
printf "%-8s " "$h"; ssh $h "date -u +%s"
done
Drift is not exotic. Virtual machines drift when the host is under load, containers inherit the host clock but a paused container resumes with a stale one, and a machine whose NTP service failed months ago drifts steadily and silently.
That last one is worth dwelling on. systemctl status chrony returning active does not mean it is synchronised. Check chronyc tracking and look at the actual offset.
Snapshot restore is the extreme version
A virtual machine restored from a snapshot resumes believing it is whenever the snapshot was taken. If that is three days ago, every token it issues is three days stale and every certificate it validates appears not yet valid.
We deal with exactly this at Krova Cloud: a restored microVM has to resync its clock before it does anything time sensitive, and it is one of the correctness details the platform handles so the workload does not have to. If you run your own snapshot restore, force an NTP step on resume rather than trusting gradual slew, because slew can take hours to close a three day gap.
Cause two: leeway that is too small, or absent
Every serious JWT library supports a tolerance window. Most default to zero, which is strict and brittle.
jwt.verify(token, secret, { clockTolerance: 30 }); // seconds
jwt.decode(token, key, algorithms=["RS256"], leeway=30)
Thirty seconds is a reasonable production value. It absorbs ordinary NTP jitter without meaningfully extending the life of a token.
Leeway is a shock absorber, not a fix. If you need 300 seconds of tolerance, your clocks are broken and you are hiding it.
Cause three: the token is genuinely expired and the refresh is racing
A token with a fifteen minute life, refreshed at fourteen minutes, will occasionally lose the race under load. The request is issued at 14:59 with a token that expires during flight.
Refresh on a proportion of the lifetime rather than a fixed margin, and refresh well before the edge:
const shouldRefresh = (exp, iat) => {
const lifetime = exp - iat;
const elapsed = Math.floor(Date.now() / 1000) - iat;
return elapsed > lifetime * 0.75;
};
Also handle concurrent refresh. Twenty requests discovering an expired token at once will fire twenty refreshes, and if the identity provider rotates refresh tokens, nineteen of them fail and log the user out. Serialise refresh through a single in flight promise.
Cause four: timezone confusion that is not really about timezones
Unix timestamps have no timezone. They are an absolute count of seconds. If someone has applied an offset to make a timestamp "local", the token is now wrong by exactly that offset.
The tell is a failure of exactly 3600, 19800 or 28800 seconds. Those are round timezone offsets, not drift.
echo "$JWT" | cut -d. -f2 | base64 -d | jq '.exp - (now|floor)'
A result near -19800 means someone applied IST as an offset to a value that was already absolute. I wrote about the wider version of this problem, where the same mistake silently corrupted a dataset rather than an auth flow.
Cause five: the browser clock
If validation happens client side, you are trusting a clock the user controls and frequently gets wrong. A laptop with a dead CMOS battery boots in 2015.
Never make an authorisation decision from a client clock. Use it only for a cosmetic hint such as "your session expires soon", and let the server be the authority on whether a token is valid.
Prevention
- Run NTP everywhere and alert on the offset, not on whether the daemon is running. Anything beyond 100 ms deserves attention; beyond a second is an incident waiting.
- Set
clockToleranceto about 30 seconds explicitly rather than accepting a zero default. - Log the actual numbers on failure.
exp, the validator'snow, and the difference. A log line that says "jwt expired" with no arithmetic wastes the first twenty minutes of every investigation. - Emit skew as a metric. Compare
iatagainst receipt time at the edge and chart the distribution. A host drifting shows up as a shifted cluster long before it starts failing. - Force a clock resync after any snapshot restore or VM resume, before the workload starts serving.