JWT Clock Skew Issues Explained — Detection and Fixes
Clock skew is a subtle but common JWT issue where tokens appear expired or invalid due to time differences between servers. Here is how to detect and fix it.
What is clock skew?
Clock skew is the difference in time between two systems. In the context of JWT authentication it occurs when the server that issues tokens and the server that verifies tokens have different clocks.
Even a small difference of 30-60 seconds can cause valid tokens to be rejected — especially tokens with short expiry times.
// Clock skew scenario Auth server time: 10:00:00 UTC App server time: 10:00:45 UTC (45 seconds ahead) Token issued: 10:00:00 Token expires: 10:01:00 (1 minute token) App server checks at: 10:00:45 (its local time) App server thinks: token expires in 15 seconds ✅ // But if app server is 90 seconds ahead: App server time: 10:01:30 App server thinks: token expired 30 seconds ago ❌
The nbf claim — not before
Clock skew can also affect the nbf (not before) claim. If the verifying server's clock is behind the issuing server's clock it may reject a valid token because nbf hasn't been reached yet.
{
"iat": 1716000000, // issued at
"nbf": 1716000000, // valid from (same as iat)
"exp": 1716003600 // expires at
}
// If verifier's clock is 60s behind:
// nbf = 10:00:00 (issuer time)
// Verifier thinks current time = 09:59:00
// Token "not yet valid" error! ❌How to detect clock skew
// Check iat vs current time
const payload = decodeJwt(token)
const issuedAt = payload.iat
const now = Math.floor(Date.now() / 1000)
const skew = now - issuedAt
console.log(`Token issued ${skew} seconds ago`)
// If skew is negative — your clock is behind issuer
// If skew is very large — possible replay attack
// Also compare with a time API
const response = await fetch('https://worldtimeapi.org/api/timezone/UTC')
const { unixtime } = await response.json()
const serverSkew = Math.abs(now - unixtime)
console.log(`Clock skew from UTC: ${serverSkew}s`)Fixes for clock skew
Fix 1 — Add clock tolerance
// Node.js / jsonwebtoken
jwt.verify(token, secret, {
clockTolerance: 60 // accept 60s skew
})
// Python / PyJWT
jwt.decode(token, key,
algorithms=["HS256"],
leeway=timedelta(seconds=60)
)
// Java / jjwt
Jwts.parserBuilder()
.setAllowedClockSkewSeconds(60)
.build()
.parseClaimsJws(token)Fix 2 — Sync server clocks with NTP
# Ubuntu/Debian sudo apt install systemd-timesyncd sudo timedatectl set-ntp true timedatectl status # Check current time sync status timedatectl show-timesync # For Docker containers # Add to Dockerfile RUN apt-get install -y ntpdate RUN ntpdate pool.ntp.org
Fix 3 — Use longer expiry times
// Instead of 60 second tokens
jwt.sign(payload, secret, { expiresIn: '1m' })
// Use at least 5-15 minutes
jwt.sign(payload, secret, { expiresIn: '15m' })
// Clock skew of 60s is negligible against 15m expiry
// Use short-lived refresh tokens separatelyCheck your JWT timestamps instantly
Decode any JWT to see iat, nbf and exp in human readable format.
Open JWT Decoder →