Authentication · advanced

How to test JWT expiry around clock disagreement

JWT verification around the expiry boundary is governed by explicit clock tolerance policy, not by raw timestamps. This guide frames the verification task: decide and prove whether tokens landing on or just past exp, nbf, or iat should be accepted, rejected, or queued for refresh, given documented skew between issuer and verifier clocks.

The symptoms

  • Users authenticated seconds ago receive 401 Unauthorized with a token-expired code while their client clock shows the token is still valid.
  • Replay of a token captured moments earlier is rejected by one verifier but accepted by another behind the same identity provider.
  • Background workers fail batch jobs because a freshly minted service-to-service JWT is reported as not-yet-valid (nbf) by the resource server.
  • Logs show a spike in exp-related errors clustered within a window that aligns with scheduled key rotation or NTP step adjustments.
  • Issued-at (iat) and not-before (nbf) claims differ from the verifier's wall clock by a stable offset that matches the issuer's documented skew, not a true replay.

Likely causes

  • No documented tolerance window on the verifier, so the implementation compares exp/nbf against the local clock with zero leeway and treats any sub-second drift as expiry.
  • Asymmetric clock sources: issuer uses a different NTP pool, container time, or VM clock than the verifier, producing a stable but unidirectional skew.
  • Tolerance is configured but the leeway value differs between services (for example, identity provider uses 30s, resource server uses 0s), causing inconsistent verdicts on the same token.
  • Clock-step events (NTP slew, VM resume from suspend, container migration) introduce transient skew that violates the verifier's monotonic-clock assumption.
  • JWT library default tolerance changed across versions, or a second library is used in one tier without the same default, producing silent policy drift.
  • Token issued with iat or nbf set in the future relative to the verifier because the issuer's clock is ahead, triggering premature rejection near the boundary.

First ten minutes

  1. 01Capture one rejected token from the verifier log and decode its header and payload locally to read exp, nbf, iat, and iss in UTC; record the verifier's wall clock at the moment of rejection.
  2. 02Compute the signed difference (verifier clock minus exp) and (nbf minus verifier clock); note which side of zero each falls on and the magnitude in seconds.
  3. 03Check whether the verifier's JWT library is configured with an explicit leeway or clockTolerance value; if absent, the default tolerance is the working hypothesis.
  4. 04Compare the issuer's reported clock source (NTP host, container time namespace, VM clock source) against the verifier's; a stable offset between them is the smoking gun for skew.
  5. 05Decide a tolerance policy in writing before changing anything: accept, reject, or refresh-queue, with a numeric leeway and a documented upper bound, so the fix step has a target.

Evidence to collect

  • The full JWT (header.payload.signature) for one rejected and one accepted token, plus the UTC wall-clock time at the verifier when each verdict was issued.
  • Issuer configuration: token TTL, whether iat/nbf are set, signing algorithm, and any documented clock source for the issuer host.
  • Verifier configuration: JWT library and version, configured leeway or clockTolerance, time-source (NTP pool, monotonic clock flag), and host timezone.
  • Recent clock-step events on issuer and verifier hosts (NTP corrections, VM resume timestamps, container migration events) within the failure window.
  • The error code and message returned to the client, specifically whether the code distinguishes exp, nbf, or generic signature/claim failure.

Where to look

  • The verifier boundary: the authentication middleware or gateway that first inspects the JWT and emits the 401; this is where exp/nbf are evaluated.
  • The issuer boundary: the identity provider's token endpoint and any signing service that stamps iat, nbf, and exp; timestamps originate here.
  • The host clock boundary: the OS time source on issuer and verifier hosts (chrony/ntpd peers, VM clock driver, container time namespace) where skew is introduced.
  • The library boundary: the JWT verification call site, including any wrapper that injects a custom Clock or Date provider; the default tolerance lives here.
  • The protocol boundary: the Set-Cookie response header carrying session or refresh metadata, since clock tolerance also affects cookie Max-Age and Expires parsing in adjacent flows.

Diagnostic steps

  1. 01Decode the rejected token's payload and compute (verifierTime - exp) and (nbf - verifierTime) in whole seconds; classify the verdict as exp-rejection, nbf-rejection, or none.
  2. 02Inspect the verifier's JWT verification call for an explicit leeway, clockTolerance, or skewSeconds parameter; record the value or note its absence to establish baseline policy.
  3. 03Compare timestamps against a known-good time source (documented NTP pool or a reference host) on both issuer and verifier to quantify the offset and direction.
  4. 04Replay the same token against two independent verifiers if available; identical payload producing different verdicts isolates tolerance configuration from signature or claim mismatch.
  5. 05Review the verifier error mapping to confirm the returned code reflects the underlying claim (exp vs nbf vs iat vs signature); ambiguous codes invalidate subsequent correlation with tolerance.
  6. 06Check the library version and release notes for any change in default tolerance; an upgrade may have silently removed a previously relied-on leeway.

Common mistakes

  • Assuming the JWT library's default tolerance is zero; many libraries apply a small default leeway (often a few seconds) that silently changes behaviour across versions.
  • Fixing clock skew on one host without auditing the other direction; a verifier that runs ahead of the issuer will reject tokens whose iat/nbf appear in the future.
  • Setting a large tolerance window to mask skew rather than fixing the underlying clock source; this expands the replay window for stolen tokens near expiry.
  • Trusting client-reported clock values; tolerance policy must be enforced server-side using a controlled time source, never the client.
  • Conflating cookie expiry (Set-Cookie Max-Age/Expires) with JWT exp; these are parsed independently and may diverge under skew even when both are nominally synchronized.
  • Ignoring monotonic-clock guarantees: backward time steps after NTP correction can invalidate tokens that were valid microseconds earlier, producing non-reproducible failures.

Safe fixes

  • If evidence shows zero configured leeway and a stable sub-second-to-second skew: introduce an explicit, documented clockTolerance at the verifier, bounded by a security-reviewed upper limit, and log the tolerance value at startup.
  • If the issuer and verifier clocks are unsynchronized: correct the underlying time source first (NTP pool alignment), then re-measure the offset before any tolerance change.
  • If library defaults changed across versions: pin the tolerance value explicitly in configuration so future upgrades do not silently alter expiry behaviour.
  • If nbf is set and verifier clock runs ahead: either remove nbf from the issuer's claim set if policy allows, or align clocks; do not paper over with tolerance.
  • If errors are ambiguous: tighten the verifier's error mapping so exp, nbf, and signature failures are reported as distinct codes, enabling future regression detection.
  • If session metadata travels in Set-Cookie alongside the JWT: ensure cookie Max-Age/Expires parsing uses the same controlled clock source as the JWT verifier, documented in the cookie policy.

Prove the fix

  1. 01Construct a test token whose exp is exactly N seconds in the future relative to the documented reference clock, where N equals the configured tolerance; the verifier must accept it.
  2. 02Construct a second token whose exp is N+epsilon seconds in the future; the verifier must reject it with the exp-specific error code, not a generic failure.
  3. 03Repeat both checks with the issuer clock artificially advanced and retarded within the documented skew envelope; verdicts must remain consistent with the policy.
  4. 04Verify in logs that the verifier emits its configured tolerance value at startup and includes the computed (verifierTime - exp) delta in the rejection record for boundary tokens.
  5. 05Replay the original rejected token captured in the first ten minutes; it must now produce the verdict dictated by policy, with the error code matching the underlying claim.

Prevention and next steps

  • Document a single, explicit clock-tolerance policy with a numeric leeway and a security rationale; require any change to pass the same review as key rotation.
  • Run issuer and verifier hosts against a shared, monitored NTP pool; alert on offset exceeding a fraction of the configured tolerance so skew is caught before users are.
  • Pin JWT library versions and set tolerance explicitly in configuration; treat any upgrade that touches claim verification as a breaking change requiring re-verification.
  • Emit distinct error codes for exp, nbf, iat, and signature failures, and dashboard them separately so tolerance regressions are visible before they become outages.
  • Include boundary-token cases (exp = now, exp = now - 1s, nbf = now + 1s) in the authentication test suite, executed against a frozen reference clock.