Authentication · beginner
JWT clock skew expiry: identify valid tokens rejected by time disagreement
A JWT that is cryptographically valid, correctly signed, and issued seconds ago is still rejected with a standard expiration-related error. The token's `exp` and `nbf` claims are evaluated against a clock that disagrees with the issuer's clock by enough to flip the time-based validity decision, so the verifier marks an otherwise good token as expired or not-yet-valid.
The symptoms
- •API returns 401 with error codes containing `TokenExpiredError`, `jwt expired`, `ERR_JWT_EXPIRED`, or `invalid jwt: exp claim` shortly after a token has just been issued.
- •A token issued seconds in the past fails at a downstream verifier with the same payload but processed minutes later in a different environment.
- •Auth-related Set-Cookie headers carry short-lived JWT cookies that the browser immediately re-requests; the verifier log shows the cookie's token as expired within seconds of issuance.
- •Successful and failed token validations cluster around changes to host time sync, NTP correction, or container/VM resume after suspend, rather than around the token's real lifetime.
- •Tokens issued by an upstream identity provider silently fail after migrating a microservice onto a new node whose system clock diverges from the IdP.
Likely causes
- •Wall-clock time on the verifier host diverges from the issuer host beyond the standard validation skew tolerance, often after a sync daemon correction.
- •NTP, chrony, or `timesyncd` is disabled, masked by a frozen VM clock, or pointed at a source that disagrees with the identity provider's clock source.
- •`clockTolerance` / `leeway` / `clockSkewSeconds` on the verifier is set to 0 (or absent, with library default at 0), so any sub-second drift flips `exp` or `nbf` evaluation.
- •The verifier uses a separate monotonic-clock abstraction or a mocked/stubbed clock in test code that has leaked into production, so `Date.now()` and the verifier's claimed current time no longer match.
- •The issuer and verifier are in containers without a stable time source, or one side caches a token whose `iat` was computed from a previously-correct clock that has since drifted.
First ten minutes
- 01Capture the failing token's unverified payload (header + claims) and record the verifier-side current time at the moment of rejection; compare `exp`, `nbf`, and `iat` against each other.
- 02Read the system clock on the issuer host and the verifier host with `timedatectl status` (or platform equivalent) and record the offset, sync status, and last correction.
- 03Inspect the verifier's JWT library configuration for `clockTolerance`, `leeway`, or equivalent skew knobs; note the numeric value and whether it is library default or explicitly set to 0.
- 04Confirm the failure window by reproducing the rejection locally: take a freshly issued token and feed it to the verifier while watching its clock, rather than relying on the original request.
- 05Decide whether the disagreement is fast (seconds, during request handling) or slow (minutes-hours, drift accumulation) before touching any configuration; this selects between tolerance tuning and clock-source repair.
Evidence to collect
- •Unverified JWT payload with `exp`, `nbf`, `iat`, and `iss` claims plus the standard expiration error message and stack trace from the verifier.
- •Verifier host clock reading, NTP/chrony/timesyncd status (`timedatectl`, `chronyc tracking`, `ntpq -p`), and a monotonic-clock-vs-wall-clock comparison if available.
- •Issuer host clock reading and its time-sync status, taken at the same moment as the verifier reading so the offset is meaningful.
- •JWT library version and the exact skew/latency setting the verifier used when deciding the token was expired.
- •Authentication log events showing the correlation between rejection timestamps and any NTP correction or container/VM time-jump events on the verifier host.
Where to look
- •At the issuer/verifier clock boundary: the system clock on each machine that creates or consumes tokens, including containers that inherit the host clock.
- •At the JWT verification middleware: the call site that evaluates `exp` and `nbf`, where the library's tolerance/clock argument is wired in.
- •At the time-sync boundary: NTP, chrony, `timesyncd`, or hypervisor time-sync settings on both the issuer and the verifier, plus any container runtime that injects a clock offset.
- •At the token transport boundary: the Set-Cookie header carrying a JWT cookie, and any proxy, gateway, or auth filter that rewrites `exp`/`iat` while forwarding requests.
- •At the test/production boundary: time-mocking utilities (`sinon.useFakeTimers`, `jest.useFakeTimers`, `clock` injection points) that can silently pin the verifier's notion of "now."
Diagnostic steps
- 01Decode the failing token without verifying it; compute `exp - iat` to learn the token's nominal lifetime, and note `exp - (verifier now)` to see how far past its expiry the verifier thinks it is.
- 02Compare the issuer's wall-clock to the verifier's wall-clock at the moment of failure; if the difference exceeds the configured skew tolerance, suspect clock disagreement rather than a long-lived token.
- 03Verify the verifier's claim handler directly: feed the same token + payload to the verifier's own validation function with `clockTimestamp` set to known values bracketing the disagreement; map the exact transition point where the token flips from valid to invalid.
- 04Replace any clock-mocking wrappers in the test path with real time and re-run the rejection locally; if the rejection disappears, the bug is a leaked clock stub, not real-world clock skew.
- 05Step back along the request path: confirm the auth middleware is the layer emitting the expired-token error, not a downstream session store or cache reporting stale metadata as a JWT failure.
Common mistakes
- •Increasing token lifetime (e.g., bumping `exp` to `iat + 1h`) instead of fixing the time source, which papers over the disagreement and leaves other time-validated tokens exposed.
- •Setting `clockTolerance` to a large constant (such as several minutes) to mask intermittent drift, which weakens the meaning of `exp` and `nbf` for every request.
- •Trusting a container's reported uptime as evidence that the clock is correct, when the host clock may have jumped during suspend/resume and the container has not re-synced.
- •Assuming "issued seconds ago, fails seconds later" means the token is malformed, and re-issuing a new token without measuring the offset; the same failure will recur on the next issuance.
- •Checking only the verifier clock and not the issuer clock; symmetric time-sync health on both sides is required to make `exp`/`nbf` meaningful.
Safe fixes
- •If the verifier's skew tolerance is set to 0 and the measured offset between issuer and verifier is sub-second, set an explicit `clockTolerance` (commonly 30 seconds) on the JWT library's verifier configuration rather than relying on implicit defaults.
- •If NTP or its equivalent is disabled or unsynchronised on the verifier host, enable it and point it at a stable upstream; verify sync before re-enabling token validation in the request path.
- •If the verifier's clock has jumped (post-suspend VM or container resume), restart the verification service after the host clock has been corrected, or use a monotonic-clock helper that clamps large backward jumps so a stale JWT is not silently re-accepted.
- •If a test or local stub is supplying a fake `Date.now()` to the verifier in production, replace the abstraction with `systemClock.now()` and add a regression test that fails when mocking crosses the verification boundary.
- •If the issuer host is drifting while the verifier is correct, repair the issuer's time sync first; tokens minted with an inaccurate `iat` will produce `exp` claims that look valid in isolation but disagree at any other verifier.
Prove the fix
- 01Replay the originally failing token through the verifier after the fix lands: the same payload that previously returned `TokenExpiredError` now returns a normal authenticated response within its nominal lifetime.
- 02Run a synthetic skew probe: mint a token whose `exp` is exactly `verifier_now + tolerance - 1s` and confirm it is accepted, and a token whose `exp` is `verifier_now + tolerance + 1s` is rejected, pinning the new tolerance boundary.
- 03Capture issuer and verifier wall-clock offsets over a 10-minute window with `timedatectl`/`chronyc tracking` style output; an acceptable fix keeps the absolute offset under the configured tolerance and the drift rate near zero.
- 04Inspect the auth log for a sustained period (e.g., one full token-rotation cycle) and confirm no new `TokenExpiredError` or `jwt expired` entries appear for tokens whose `exp - iat` is shorter than the documented lifetime.
- 05Force the verifier host clock backward by a small, safe amount (within the chosen tolerance) in a non-production environment and confirm legitimate tokens are still accepted, and tokens beyond `exp + tolerance` are still rejected.
Prevention and next steps
- •Run a periodic clock-offset check between every host that issues tokens and every host that verifies them; alert when the offset approaches the configured skew tolerance.
- •Make the JWT library's skew tolerance an explicit configuration value, documented and reviewed, rather than leaving it at the library default which may be 0.
- •Keep time-sync services managed (NTP/chrony/timesyncd) under infrastructure as code so they survive host rebuilds, container moves, and autoscaling events.
- •Add an integration test that issues a token, deliberately introduces a small clock offset on the verifier, and asserts the configured tolerance is honoured; this catches regressions in both the clock source and the verifier configuration.
- •Audit auth middleware for clock-injection points (test fakes, monotonic-clock helpers, custom `now()` functions) so a stray mock cannot reach production verification paths.
Safe commands and checks
timedatectl status chronyc tracking ntpq -p date -u +%s.%N printf '%s' <token> | cut -d. -f2 | base64 -d 2>/dev/null; echo grep -E 'TokenExpiredError|jwt expired|ERR_JWT_EXPIRED' /var/log/<auth-service>.log