> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# JWT Expired: When the Token Is Fine and the Clock Is Not
- URL: https://debugly.dev/jwt-expired-clock-skew-debugging/
- Published: 2026-08-27T08:55:00.000Z
- Updated: 2026-10-23T09:13:03.000Z
- Description: Tokens rejected as expired seconds after issue, or accepted long after they should have died. Both are the same bug seen from opposite ends.
- Author: Rohit Bhadani
- Tags: Security, Error Autopsy, Networking, Debugging

## The quick version

```
JsonWebTokenError: jwt expired
TokenExpiredError: jwt expired at 2026-08-27T08:40:12.000Z

```

Before you touch the token logic, compare the two clocks:

```bash
# 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:

```bash
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.

```js
// 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.

```bash
# 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](https://krova.cloud/?ref=debugly.dev): 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.

```js
jwt.verify(token, secret, { clockTolerance: 30 });   // seconds

```

```python
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:

```js
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.

```bash
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](https://debugly.dev/timezone-bug-corrupted-analytics/), 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 `clockTolerance` to about 30 seconds explicitly rather than accepting a zero default.
- Log the actual numbers on failure. `exp`, the validator's `now`, 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 `iat` against 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.

---

Related reading: [The Cert Rotated at Midnight and the Pool Kept the Old Handshake](https://debugly.dev/tls-rotation-pooled-old-handshake/) explores a connected angle.

Related reading: [ETIMEDOUT, ECONNREFUSED, EHOSTUNREACH: Three Diagnoses](https://debugly.dev/etimedout-econnrefused-ehostunreach-differences/) explores a connected angle.

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)