JWT Expiry, Timestamps and Clock Skew

· bugssecurity

Pass Date.now() into exp and you have issued a token expiring in the year 56,000. Here is the whole class of timestamp bug around JWTs.

JSON Web Tokens carry three timestamp claims, all defined by RFC 7519 as NumericDate — the number of seconds since the Unix epoch, as a JSON number.

Claim Meaning
exp Expiration time. The token must be rejected at or after this instant.
iat Issued at. When the token was created.
nbf Not before. The token must be rejected before this instant.

All three are seconds, not milliseconds. That single fact accounts for most JWT timestamp bugs.

The milliseconds mistake

// WRONG — Date.now() is milliseconds
const token = jwt.sign({ exp: Date.now() + 3600000 }, secret);

// RIGHT — seconds
const token = jwt.sign({ exp: Math.floor(Date.now() / 1000) + 3600 }, secret);

The wrong version produces an exp of roughly 1.7 × 10¹², which as seconds is about the year 56,000. The token effectively never expires.

What makes this pernicious is that it usually works. Lenient validators accept the token happily, so the bug ships. It surfaces later as a security finding, or as an intermittent failure when one service in a fleet validates strictly and others do not.

The reverse mistake — passing seconds where a library expects milliseconds — produces a token that expired in 1970 and is rejected immediately. That one you find in testing.

Most libraries accept an expiresIn option in seconds and compute exp for you. Use it rather than doing the arithmetic.

Reading a token’s expiry

The payload is base64url-encoded JSON, not encrypted — anyone holding the token can read the claims:

echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
const payload = JSON.parse(atob(token.split('.')[1]));
new Date(payload.exp * 1000).toISOString();

Paste the exp value into the countdown page to see exactly how long remains, or into the converter for the full date.

Decoding tells you what a token claims. It says nothing about whether the signature is valid — never make an authorisation decision from a decoded payload without verifying.

Clock skew

exp is compared against the validating server’s clock. If the issuer’s clock and the validator’s clock disagree, tokens fail at their boundaries:

  • Validator ahead of issuer → tokens appear to expire early.
  • Validator behind issuer → a token can be rejected for nbf immediately after issue, the classic “token from the future” error.

RFC 7519 anticipates this and permits “a small leeway, usually no more than a few minutes”. In practice 30 to 60 seconds is the common choice.

import jwt
jwt.decode(token, key, algorithms=["RS256"], leeway=30)
jwt.verify(token, key, { clockTolerance: 30 });   // seconds

Leeway is a mitigation, not a fix. If you need more than about a minute, your clocks are genuinely broken — run NTP (chrony or systemd-timesyncd) on every host and monitor the offset. Containers inherit the host clock, so the host is what matters.

Symptoms of skew as a root cause: authentication failures clustered on one node, failures that resolve on retry, or errors that appear only for tokens near their expiry boundary.

Choosing an expiry

Short access tokens with refresh tokens is the standard pattern:

  • Access token: 5–15 minutes. Short enough that a leaked token has limited value. Because JWTs are self-contained, they cannot be revoked before expiry — the expiry is your revocation window.
  • Refresh token: days to weeks. Stored server-side so it can be revoked, and rotated on each use.

Anything issuing an access token valid for a year has effectively created a permanent credential that cannot be withdrawn.

Validation checklist

  1. Verify the signature first, and pin the expected algorithm. Never trust the alg header — alg: none and RS256-to-HS256 confusion are both real attacks.
  2. Check exp with a small leeway.
  3. Check nbf if present, with the same leeway.
  4. Check iss and aud against expected values.
  5. Reject tokens whose iat is implausibly far in the future.
  6. Use a maintained library. Do not hand-roll validation.

JWTs use integer seconds, but adjacent parts of the same system rarely do:

  • OAuth 2 expires_in — a duration in seconds, not an instant. Feeding it to a countdown gives a date in 1970.
  • HTTP Expires — an RFC 7231 date string, not an epoch.
  • Cache-Control: max-age — a duration in seconds.
  • TLS notAfter — a certificate date, typically shown in ASN.1 UTCTime.

Mixing an absolute instant with a duration is the second most common bug in this area, after seconds versus milliseconds. See seconds vs milliseconds.

Related guides