JWT clock skew: why your tokens expire before they should

Your token has a 15-minute TTL. A user authenticates, gets a token, and immediately calls your API — only to receive a 401. Your logs show the token was rejected because it was "not yet valid." The token was issued 200 milliseconds ago. What went wrong?

Clock skew. The server that issued the token and the server that validated it have clocks that disagree by more than the validation window. This is one of those distributed systems problems that works perfectly in development (single machine, one clock) and breaks unpredictably in production (multiple hosts, NTP drift, container rescheduling, load balancers routing to different nodes).

The relevant JWT time claims

Three JWT claims govern time-based validity:

  • iat (issued at): Unix timestamp of when the token was issued. Informational — most validators don't enforce a rule on it beyond using it to compute age.
  • nbf (not before): The token must not be accepted before this time. If omitted, the token is valid immediately upon issuance.
  • exp (expires at): The token must not be accepted at or after this time. Required for access tokens.

The standard validation check is: nbf <= now < exp. On a single host, this is fine. Across hosts with independent clocks, it breaks in both directions: a token can appear expired before it actually expires (now on the validator is ahead of the issuer's clock), or valid before it should be (now on the validator is behind).

How much drift exists in practice

NTP (Network Time Protocol) keeps servers synchronized within about 10–50 milliseconds under normal network conditions. However:

  • Containerized workloads on Kubernetes inherit the host clock, which syncs less frequently under load.
  • VM migration (live migration in hypervisors) can cause clock jumps of seconds.
  • AWS EC2 instances behind heavy CPU load have been observed drifting by several seconds.
  • Serverless functions (AWS Lambda, Cloudflare Workers) can have clocks that are seconds off when a cold container starts.

The RFC 7519 JWT specification acknowledges this: "Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew." The informal industry standard is a ±5-second leeway window, which is what most JWT libraries implement as their default.

Implementing leeway correctly

import jwt from 'jsonwebtoken';

const CLOCK_LEEWAY_SECONDS = 5;

function verifyAccessToken(token: string, publicKey: string): jwt.JwtPayload {
  try {
    // jsonwebtoken's clockTolerance applies the leeway to both nbf and exp checks
    const payload = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],
      audience: 'https://api.example.com',
      issuer: 'https://auth.example.com',
      clockTolerance: CLOCK_LEEWAY_SECONDS,
    }) as jwt.JwtPayload;

    return payload;
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      throw new AuthError('TOKEN_EXPIRED', `Token expired at ${err.expiredAt.toISOString()}`);
    }
    if (err instanceof jwt.NotBeforeError) {
      throw new AuthError('TOKEN_NOT_YET_VALID', `Token not valid until ${err.date.toISOString()}`);
    }
    throw new AuthError('TOKEN_INVALID', (err as Error).message);
  }
}
import jwt
from datetime import timezone

def verify_access_token(token: str, public_key: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            public_key,
            algorithms=["RS256"],
            audience="https://api.example.com",
            issuer="https://auth.example.com",
            leeway=5,  # seconds — applied to both nbf and exp
            options={
                "verify_exp": True,
                "verify_nbf": True,
                "verify_iat": True,
            }
        )
        return payload
    except jwt.ExpiredSignatureError as e:
        raise TokenExpiredError(str(e))
    except jwt.ImmatureSignatureError as e:
        raise TokenNotYetValidError(str(e))
    except jwt.InvalidTokenError as e:
        raise TokenInvalidError(str(e))
The leeway applies symmetrically: a 5-second leeway means a token is accepted up to 5 seconds after its exp and up to 5 seconds before its nbf. Keep it at 5 seconds or less. A 60-second leeway is not a "safe" fix for clock problems — it's a 60-second window where expired tokens remain usable.

The iat claim and token age limits

The iat claim lets you enforce a maximum token age independent of the exp claim. This is useful when you want to reject tokens issued before a significant security event — for example, after a password change, you want to reject all tokens issued before the change even if they haven't technically expired yet.

function verifyTokenWithAgeLimit(token: string, maxAgeSecs: number): jwt.JwtPayload {
  const payload = verifyAccessToken(token, publicKey);

  const now = Math.floor(Date.now() / 1000);
  const tokenAge = now - (payload.iat ?? 0);

  if (tokenAge > maxAgeSecs) {
    throw new AuthError('TOKEN_TOO_OLD', `Token is ${tokenAge}s old, max is ${maxAgeSecs}s`);
  }

  // Check against user's credential-change timestamp
  // If the token was issued before the password was changed, reject it
  const user = await db.users.findById(payload.sub);
  if (user.credentialsChangedAt && payload.iat < user.credentialsChangedAt.getTime() / 1000) {
    throw new AuthError('TOKEN_SUPERSEDED', 'Credentials changed after token was issued');
  }

  return payload;
}

Diagnosing clock skew in production

When you see intermittent JWT validation failures that don't correlate with user activity, clock skew is the likely culprit. Add structured logging to your token validation path:

function verifyWithDiagnostics(token: string): jwt.JwtPayload {
  const decoded = jwt.decode(token) as jwt.JwtPayload;
  const now = Math.floor(Date.now() / 1000);

  if (decoded) {
    const skew = now - (decoded.iat ?? now);
    const timeToExpiry = (decoded.exp ?? 0) - now;

    logger.debug('jwt_validation', {
      iat: decoded.iat,
      exp: decoded.exp,
      now,
      skew_seconds: skew,
      ttl_remaining: timeToExpiry,
      host: os.hostname(),
    });

    if (Math.abs(skew) > 10) {
      logger.warn('jwt_clock_skew_detected', {
        skew_seconds: skew,
        host: os.hostname(),
        sub: decoded.sub,
      });
    }
  }

  return verifyAccessToken(token, publicKey);
}

Aggregate the skew_seconds metric per host in your observability platform. A host consistently showing skew greater than 5 seconds needs its NTP configuration investigated. On Kubernetes, check that the node's chronyd or ntpd is running and that the node's CPU load is not preventing timely NTP corrections. On EC2, verify that the AWS Time Sync Service is configured as the NTP source — it's significantly more accurate than public NTP servers for AWS-internal workloads.

The fix for clock skew is always the same: ensure NTP is running and accurate on all hosts that issue or validate tokens. Leeway is a tolerance for the unavoidable residual drift, not a substitute for proper time synchronization.