JWT 'jwt expired' on Fresh Tokens: Fix Clock Skew

JWT verify fails with 'jwt expired' or 'token not yet valid' on tokens issued seconds ago. Fix server clock drift with NTP and add 5s verifier leeway.

Users report sporadic 401s right after login. The token was minted three seconds ago and the verifier swears it expired. The cause is almost never the token — it is clock drift between the auth server that signed iat/exp and the resource server that checked exp. The same drift shows up as nbf (“not before”) and iat (“issued at”) rejections immediately after issuance.

Fastest fix: sync the verifier’s clock with NTP (chrony), then add a 5-second clockTolerance/leeway to your verifier. With both in place these false rejections disappear. The rest of this page is the diagnosis and the exact code per library.

Match your error string first

Different libraries throw different messages for the same skew. Find yours, then jump to the fix.

Error string / classLibraryWhat it meansSkew direction
TokenExpiredError: jwt expiredjsonwebtoken (Node)exp < now on the verifierVerifier clock ahead
ERR_JWT_EXPIREDjose (Node)exp < nowVerifier clock ahead
NotBeforeError: jwt not activejsonwebtoken (Node)nbf > nowVerifier clock behind, or issuer ahead
ERR_JWT_CLAIM_VALIDATION_FAILED ("nbf" claim timestamp check failed)jose (Node)nbf > nowVerifier behind / issuer ahead
ExpiredSignatureErrorPyJWT / python-joseexp < nowVerifier ahead
ImmatureSignatureError: The token is not yet valid (iat)PyJWTiat > now + leewayIssuer ahead of verifier
ImmatureSignatureError: The token is not yet valid (nbf)PyJWTnbf > nowVerifier behind / issuer ahead
token has invalid claims: token is expiredgolang-jwt/jwt/v5exp < nowVerifier ahead
token has invalid claims: token is not valid yetgolang-jwt/jwt/v5nbf > nowVerifier behind / issuer ahead

If the message includes “expired” the verifier clock is most likely ahead of the issuer. If it says “not yet valid” / “not active” the verifier is behind the issuer (or the issuer is fast). One PyJWT-specific trap: as of June 2026 PyJWT still validates iat by default (verify_iat: True), so a fast issuer makes a brand-new token look like it was “issued in the future” and PyJWT rejects it with The token is not yet valid (iat). The same leeway argument covers it.

Which bucket are you in?

Ordered by hit rate.

#CauseHow to spot it
1Resource (verifier) server clock drifted forwardchronyc tracking shows a large System time offset, or timedatectl status reports System clock synchronized: no
2Container/VM with no NTP disciplinedate -u inside two pods/hosts differs by seconds; drift grows after a VM pause/resume or snapshot restore
3JWT library has zero leeway by defaultErrors cluster on the very last second of the token lifetime
4nbf set to issuance time on a slightly fast issuerError says “not yet valid” / NotBeforeError / ImmatureSignatureError right after login
5Cached time on serverless cold startError spike correlates with cold starts after a long idle gap

Causes 1, 2 and 5 are clock problems — fix the clock. Causes 3 and 4 are also masked by adding leeway, but leeway is a band-aid; a host more than a few seconds out of sync is the real bug.

Shortest path to fix

Step 1: Confirm and quantify the skew

# Compare wall clock to a known-good remote source (HTTP Date header is UTC)
curl -s --head https://www.google.com | grep -i ^date
date -u

# On the host
timedatectl status
chronyc tracking

Healthy chronyc tracking looks like this:

Reference ID    : A9FEA97B (time.cloudflare.com)
Stratum         : 3
System time     : 0.000031 seconds slow of NTP time
Leap status     : Normal

The two lines that matter: System time should be a few milliseconds, and Leap status should be Normal (not Not synchronised). If System time is anything over ~100 ms, fix the clock before touching JWT code — leeway will not save you from a host that is seconds out.

Step 2: Run NTP everywhere

On systemd hosts, use systemd-timesyncd (simple SNTP client) or chrony (full NTP, better for VMs that pause/resume). chrony handles step corrections after a jump more gracefully, which is exactly the failure mode here.

# Ubuntu/Debian with chrony
sudo apt install -y chrony
sudo systemctl enable --now chrony   # service is 'chrony' on Debian/Ubuntu, 'chronyd' on RHEL/Fedora
chronyc sources -v
chronyc tracking
# /etc/chrony/chrony.conf  (Debian/Ubuntu)  or  /etc/chrony.conf  (RHEL)
pool time.google.com iburst
pool time.cloudflare.com iburst
makestep 1.0 3        # step (not slew) the clock if off by > 1s in the first 3 updates
rtcsync               # keep the hardware RTC in sync

makestep 1.0 3 is the line that matters for this bug: it lets chrony jump the clock at startup instead of slewing slowly, so a freshly booted or resumed host is correct within seconds instead of minutes.

For Kubernetes, NTP runs on the node, not the pod — pods read the node’s clock, so there is no per-pod NTP to configure. Make sure every node has chrony/systemd-timesyncd running. For VMs after a snapshot/restore, force a one-time step:

sudo chronyc makestep

Step 3: Add leeway in the JWT verifier

Leeway is sanctioned by the spec itself: RFC 7519 §4.1.4 says implementers “MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew.” Five seconds is the common production default and absorbs ordinary network and scheduling jitter. Keep it small — 30 s is a reasonable ceiling, and going higher meaningfully weakens the exp time bound.

Node (jsonwebtoken):

import jwt from 'jsonwebtoken';

jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  clockTolerance: 5,   // seconds of leeway for iat/exp/nbf
});

Node (jose, modern):

import { jwtVerify } from 'jose';

const { payload } = await jwtVerify(token, key, {
  clockTolerance: '5s',   // string ('5s') or number of seconds (5)
});

Python (PyJWT — the actively maintained choice; leeway is a top-level argument, not inside options). The same leeway covers exp, nbf, and the default iat check, so it stops ExpiredSignatureError and both ImmatureSignatureError variants:

import jwt  # PyJWT (2.13.0 as of June 2026)
jwt.decode(token, key, algorithms=['RS256'], leeway=5)
# ExpiredSignatureError (exp) / ImmatureSignatureError (nbf or iat) on failure

Python (python-jose, if you are already on it — note it is effectively unmaintained, so prefer PyJWT for new code; here leeway does go inside options):

from jose import jwt
jwt.decode(token, key, algorithms=['RS256'], options={'leeway': 5})

Go (golang-jwt/jwt/v5):

parser := jwt.NewParser(jwt.WithLeeway(5 * time.Second))
token, err := parser.Parse(tokenStr, keyFunc)

Note: clockTolerance/leeway applies symmetrically to exp, nbf, and iat, so a single setting fixes both the “expired” and the “not yet valid” variants.

Step 4: Shorten token TTL once clocks are trustworthy

With NTP in place and 5 s leeway, you can keep short access tokens (5-15 min) and use refresh tokens for the long tail. That limits the blast radius if a token leaks.

// Issue 10 min access, 30 day refresh
const access = jwt.sign({ sub }, key, { algorithm: 'RS256', expiresIn: '10m' });
const refresh = jwt.sign({ sub, typ: 'refresh' }, key, { algorithm: 'RS256', expiresIn: '30d' });

Step 5: Monitor for skew so it cannot silently return

Emit a metric of now() - iat at the verifier. If the distribution shifts, you have new drift somewhere.

const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat;
metrics.histogram('jwt_age_at_verify_seconds').record(ageSeconds);

Alert if p99 goes negative (the verifier clock is behind the issuer) by more than 2 s — a negative token age is physically impossible without skew.

How to confirm it’s fixed

  1. chronyc tracking reports Leap status : Normal and System time under ~50 ms on the verifier and the issuer.
  2. date -u on the issuer and verifier match to the second.
  3. Mint a token and verify it immediately in a loop; with leeway set you should see zero jwt expired / not yet valid errors.
# Quick same-host sanity loop (Node example wrapped in a script): expect 0 failures
for i in $(seq 1 50); do node verify-fresh-token.js || echo "FAIL $i"; done

If the loop still fails, the two clocks are still apart — recheck Step 1 on both the issuer and the verifier, not just one.

Prevention

  • Run NTP (chrony) on every host, with at least two pools listed for redundancy.
  • Set an explicit clockTolerance/leeway of 5 s on every JWT verifier — never rely on the library default of 0.
  • Bake chronyc makestep into VM resume hooks and large maintenance windows so a paused host corrects instantly.
  • Treat clock skew as an SLO: alert when any node drifts more than 100 ms.
  • For multi-region deployments, prefer the same NTP source per region to avoid clocks bouncing between references.

FAQ

Why does only one server reject the token while others accept it? That one server’s clock has drifted ahead (for exp errors) or behind (for nbf errors). The token is fine; run chronyc tracking on the rejecting host and compare its System time offset to a known-good source.

Is adding clockTolerance a security risk? A small value (5 s, up to ~30 s) is standard and safe — it only widens the validity window by that many seconds. Large values (minutes) do weaken the exp guarantee, so fix the clock instead of cranking leeway up.

My clocks are synced but I still get random jwt expired near the end of token life — why? Sub-second skew plus network latency can still trip a zero-leeway verifier exactly at the exp boundary. This is cause #3: set clockTolerance/leeway to 5 s and it stops.

Does this affect serverless (Lambda / Cloud Run / Cloud Functions)? Yes — some runtimes sync time lazily on cold start, so the first request after a long idle gap can see skew. You cannot run NTP inside the function, so rely on a small verifier leeway and keep tokens short; the platform corrects the clock shortly after.

nbf/not yet valid right after login — different cause? Same root cause, opposite direction: the issuer’s clock is ahead of the verifier, so a token’s nbf (often set to issuance time) looks like it is in the future. Sync clocks and the same 5 s leeway covers it.

It only breaks on my laptop after it wakes from sleep — why? Docker Desktop’s Linux VM (and any VM) can drift while the host sleeps, then read its old clock on resume. Restart the VM or run a one-time step inside the container’s host (sudo chronyc makestep, or restart Docker Desktop) and the skew clears. This is the same cause #2 (paused VM) seen on a dev box rather than in prod.

Should I just remove exp/nbf checks to make it go away? No. Disabling claim validation defeats the point of expiring tokens. Sync clocks and add small leeway — that fixes the symptom without removing the security control. If a fast issuer keeps tripping PyJWT’s iat check specifically, the spec-clean fix is leeway, not disabling validation.

Tags: #Backend #Troubleshooting #jwt