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 / class | Library | What it means | Skew direction |
|---|---|---|---|
TokenExpiredError: jwt expired | jsonwebtoken (Node) | exp < now on the verifier | Verifier clock ahead |
ERR_JWT_EXPIRED | jose (Node) | exp < now | Verifier clock ahead |
NotBeforeError: jwt not active | jsonwebtoken (Node) | nbf > now | Verifier clock behind, or issuer ahead |
ERR_JWT_CLAIM_VALIDATION_FAILED ("nbf" claim timestamp check failed) | jose (Node) | nbf > now | Verifier behind / issuer ahead |
ExpiredSignatureError | PyJWT / python-jose | exp < now | Verifier ahead |
ImmatureSignatureError: The token is not yet valid (iat) | PyJWT | iat > now + leeway | Issuer ahead of verifier |
ImmatureSignatureError: The token is not yet valid (nbf) | PyJWT | nbf > now | Verifier behind / issuer ahead |
token has invalid claims: token is expired | golang-jwt/jwt/v5 | exp < now | Verifier ahead |
token has invalid claims: token is not valid yet | golang-jwt/jwt/v5 | nbf > now | Verifier 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.
| # | Cause | How to spot it |
|---|---|---|
| 1 | Resource (verifier) server clock drifted forward | chronyc tracking shows a large System time offset, or timedatectl status reports System clock synchronized: no |
| 2 | Container/VM with no NTP discipline | date -u inside two pods/hosts differs by seconds; drift grows after a VM pause/resume or snapshot restore |
| 3 | JWT library has zero leeway by default | Errors cluster on the very last second of the token lifetime |
| 4 | nbf set to issuance time on a slightly fast issuer | Error says “not yet valid” / NotBeforeError / ImmatureSignatureError right after login |
| 5 | Cached time on serverless cold start | Error 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
chronyc trackingreportsLeap status : NormalandSystem timeunder ~50 ms on the verifier and the issuer.date -uon the issuer and verifier match to the second.- Mint a token and verify it immediately in a loop; with leeway set you should see zero
jwt expired/not yet validerrors.
# 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/leewayof 5 s on every JWT verifier — never rely on the library default of 0. - Bake
chronyc makestepinto 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.
Related
- Auth redirect wrong
- CORS error
- Backend Postgres connection pool exhausted
- Backend gRPC deadline exceeded
- Local vs prod mismatch
- Rate limit issue
Tags: #Backend #Troubleshooting #jwt