Authentication · advanced

JWT expiry checklist

A practical debugging checklist for engineers chasing JWT expiry bugs where tokens are accepted or rejected at unexpected time boundaries. The argument: expiry bugs are rarely about the exp claim itself; they live at the seams between issuer clock, system clock, leeway configuration, clock skew, and the boundary where the verifier evaluates the exp/nbf/iat triplet. This guide walks triage from the first observable symptom to a regression-grade proof of fix, anchored to HTTP Set-Cookie behavior and JWT structural rules.

The symptoms

  • Users are logged out earlier than the documented session length, with the verifier returning "token expired" even though the token's exp claim visually looks in the future.
  • Users are kept logged in longer than intended: tokens with past exp are still accepted, often after a server restart or NTP time correction.
  • Expiry behavior drifts by exactly N seconds (commonly 1, 60, or 3600) between environments, suggesting a clock-skew or unit-conversion boundary rather than a logic bug.
  • Tokens issued by service A are rejected by service B with a "jwt expired" error despite shared issuer configuration, pointing to a leeway or clock divergence at the verify boundary.
  • Cookies carrying the JWT are being cleared or rewritten unexpectedly, so the verifier never even sees the token; the boundary is the browser-to-server HTTP hop, not the JWT itself.

Likely causes

  • Clock skew between the issuing host and the verifying host, with the verifier applying a strict (zero or negative) leeway window against exp and nbf.
  • Unit mismatch on the exp claim: seconds since epoch vs. milliseconds since epoch, often introduced by a language standard library that mixes Date.now() and time.time().
  • iat or nbf set in the future relative to the verifier's clock, causing an early rejection that looks like an expiry issue but is actually a not-yet-valid boundary.
  • Cookie attributes on the Set-Cookie header that drop the JWT before it reaches the verifier: an HttpOnly Secure mismatch on a non-HTTPS dev path, a wrong Path or Domain, or a SameSite/Partitioned policy that strips the cookie on cross-site hops.
  • Token refresh logic that reuses a long-lived refresh window after a clock change, so a token that should be expired is silently re-issued from cached claims.
  • Library-level default leeway changed between versions, so a previously tolerated skew is now rejected, or vice versa.

First ten minutes

  1. 01Capture the exact error verbatim from the verification path, including library name and version, and identify whether the rejection cites exp, nbf, or iat.
  2. 02Decode the JWT header and payload (without trusting the signature) and record iss, sub, aud, iat, nbf, exp, and jti, plus the units the issuer claims to use.
  3. 03Compare the verifier's wall clock to the issuer's wall clock at the moment of failure; record the delta in seconds and note whether NTP is enabled on both hosts.
  4. 04Inspect the Set-Cookie response that delivered the JWT and record Expires, Max-Age, Path, Domain, Secure, HttpOnly, SameSite, and Partitioned attributes exactly as sent.
  5. 05Reproduce the failure with a controlled clock: roll the verifier host's time forward and backward by a known offset and re-run the same request to confirm the boundary is time-driven.
  6. 06Check whether the affected path uses a cookie or a Bearer header; an expiry "bug" on a Bearer path is almost never a cookie expiry problem.

Evidence to collect

  • Decoded JWT payload fields (iat, nbf, exp, iss, aud, jti) and the issuer's stated unit convention, captured at the moment of failure.
  • Verifier-side timestamp at the moment of rejection, plus the configured leeway value, captured from the verifier's own clock, not from the client.
  • Raw Set-Cookie header from the response that issued the token, with each attribute recorded verbatim.
  • NTP status and time offset for both the issuing and verifying hosts at the moment of failure, taken from the host's time synchronization service.
  • Library version and the documented default leeway and clock tolerance for the JWT verifier in use on the failing path.
  • For each failing token, the exact delta in seconds between the verifier's now and the token's exp, to confirm whether the rejection is within or outside the configured leeway window.

Where to look

  • The verifier's clock evaluation boundary: the precise line where the library compares Date.now() or time.time() to the exp claim, including any leeway added or subtracted.
  • The HTTP boundary between the identity provider and the application: the Set-Cookie response header attributes and the matching Cookie request header that the browser actually returns.
  • The issuer's claim construction boundary: where iat, nbf, and exp are computed and in which units (seconds vs. milliseconds), and whether a single helper function is used across services.
  • The host clock boundary: the OS time, the container's view of time, and the NTP service, especially after a leap second, a VM resume, or a daylight saving transition.
  • The refresh-token issuance boundary: the code path that decides whether to mint a new access token or reuse cached claims, because cached claims can outlive a clock correction.
  • The library-version boundary: the changelog and documented defaults for leeway and clock tolerance in the verifier library, since defaults often change silently between minors.

Diagnostic steps

  1. 01Compute the residual: now_verifier - exp_token. If the residual is a small positive number equal to the documented leeway, expiry is enforced correctly; if it is significantly larger or smaller, suspect a unit or skew issue.
  2. 02Test the hypothesis of a unit mismatch by searching the issuer code for exp = and confirming whether the value is built from time.time() (seconds) or Date.now() (milliseconds); a 1000x ratio is the classic signature.
  3. 03Test the hypothesis of clock skew by running a controlled request after deliberately shifting the verifier host's clock by a known offset and observing whether the rejection threshold moves by the same offset.
  4. 04Test the hypothesis of a cookie stripping boundary by inspecting whether the browser is sending the cookie at all on the failing path; a missing cookie means the verifier never sees the token, so exp is irrelevant.
  5. 05Test the hypothesis of a leeway regression by pinning the verifier library to a previous version and rerunning the same failing token; if the same token is accepted, the boundary moved at the library default.
  6. 06Test the hypothesis of an nbf/iat boundary by decoding the token and confirming that the verifier's now is not before nbf or iat; a rejection that cites exp but is actually triggered by nbf points to a misconfigured not-before window.
  7. 07Cross-check the issuer and verifier against the official Set-Cookie attribute semantics to rule out Path, Domain, Secure, and SameSite interactions that can drop the token before the verifier ever runs.

Common mistakes

  • Assuming "token expired" always means the exp claim is at fault, when the actual boundary is nbf or iat, or a cookie that never reaches the verifier.
  • Comparing client-side and server-side timestamps in the same unit; a 1000x mismatch silently produces tokens that look expired in seconds but are valid in milliseconds, or vice versa.
  • Reading the JWT payload with a friendly decoder that formats exp as a human date and trusting that formatted value, instead of comparing the raw claim to the verifier's raw clock.
  • Changing leeway upward to "fix" the symptom without first proving that clock skew is the cause, which masks the real bug and can keep stale tokens alive longer than policy allows.
  • Looking at server logs alone; the Set-Cookie attributes and the Cookie request header are the authoritative cookie-side evidence, and they are only visible on the HTTP boundary.
  • Trusting a cached token replay path that re-signs claims from memory, because cache can outlive a clock correction and effectively extend the token's real-world lifetime.

Safe fixes

  • Conditional on a confirmed unit mismatch: standardize the issuer and verifier on seconds since epoch (NumericDate) and add an explicit assertion at the issuance boundary that exp is strictly greater than iat and within a sane window.
  • Conditional on a confirmed clock skew: enable NTP on both hosts, set an explicit, documented leeway at the verifier boundary, and record the configured leeway in the verification metric so future regressions are visible.
  • Conditional on a confirmed cookie boundary: correct the Set-Cookie attributes (Path, Domain, Secure, HttpOnly, SameSite) so the token is actually delivered to the verifier, and verify the Cookie request header on the failing path before changing JWT logic.
  • Conditional on a confirmed library default change: pin the verifier library version, document the leeway default in code, and add a startup log line that records the configured leeway and clock tolerance.
  • Conditional on a confirmed nbf/iat misconfiguration: stop setting nbf in the future, or set it to a value within the documented leeway, and ensure the verifier's leeway is applied symmetrically to exp and nbf.
  • Conditional on a confirmed refresh-token reuse path: rebuild the access token from a re-validated clock at refresh time, rather than reusing long-lived cached claims that may predate a clock correction.

Prove the fix

  1. 01Regression check: replay the originally failing token against the patched verifier and confirm the rejection boundary now matches the configured leeway to within one second, captured from the verifier's own clock.
  2. 02Boundary check: with a controlled verifier clock offset of plus 60 seconds and minus 60 seconds, confirm that a token whose exp is exactly now is accepted within leeway and rejected outside leeway, and that the result is identical for both positive and negative offsets.
  3. 03Cookie boundary check: on the failing HTTP path, confirm that the response sets the JWT cookie with the intended attributes and that the subsequent request includes the cookie in the Cookie header, recorded verbatim from the HTTP exchange.
  4. 04Unit boundary check: decode a freshly issued token and confirm that exp - iat equals the expected lifetime in seconds, not milliseconds, and that the value type is numeric, not a string.
  5. 05Library boundary check: capture the verifier's configured leeway and clock tolerance at startup and log them on every process boot, so a future default change is immediately visible in operational logs.
  6. 06End-to-end check: with NTP disabled on a test verifier host, run a token whose exp is in the past by a value inside the configured leeway and confirm it is accepted, then run a token whose exp is in the past by a value outside leeway and confirm it is rejected; both results must be reproducible.

Prevention and next steps

  • Define a single helper for JWT claim construction that always uses seconds since epoch, and a single helper for verification that always applies a documented leeway; both helpers should be the only path used to mint or check tokens.
  • Treat the Set-Cookie attributes on the issuing response and the Cookie attributes on the consuming request as a first-class contract, versioned and reviewed alongside the JWT schema.
  • Run hosts under NTP with monitoring on offset, and alert when the offset between the issuer and verifier exceeds the configured leeway, because the leeway is the true upper bound on tolerable skew.
  • Pin JWT library versions and record the actual leeway and clock tolerance values at startup, so a silent default change between minors cannot quietly widen or narrow the expiry window.
  • Add a synthetic JWT expiry probe in the test suite that issues a token with a past exp and a known leeway, and asserts the verifier's accept or reject result; this catches both unit and library regressions at CI time.

Safe commands and checks

# Decode JWT payload (no signature verification) using Python, units-agnostic. Replace <token> with the actual JWT captured at the failure boundary. python3 -c "import base64,json,sys; t=sys.argv[1].split('.'); print(json.dumps(json.loads(base64.urlsafe_b64decode(t[1] + '===')), indent=2))" <token>
# Compare the verifier's wall clock to the token's exp, in seconds since epoch. Replace <token> and capture both values from the verifier host, not the client. python3 -c "import base64,json,time,sys; t=sys.argv[1].split('.'); p=json.loads(base64.urlsafe_b64decode(t[1] + '===')); print('exp=', p.get('exp'), 'now=', int(time.time()), 'delta=', int(time.time()) - int(p.get('exp', 0)))" <token>
# Inspect the raw Set-Cookie header from the response that issued the JWT. Capture this from the HTTP exchange, not from a parsed log line, and confirm Path, Domain, Expires, Max-Age, Secure, HttpOnly, SameSite, and Partitioned attributes verbatim.
# Check the verifier host's NTP offset and time synchronization status. Replace <host> with the verifier host identifier accessible from the operator's tooling. ntpdate -q <host> || chronyc tracking
# Record the configured JWT verifier leeway at startup and log it on every process boot. Replace <leeway_seconds> with the actual configured value, never a placeholder. python3 -c "import logging; logging.warning('jwt_verifier_leeway_seconds=%s', <leeway_seconds>)"
# Reproduce a controlled-clock boundary test by issuing a token with exp equal to now and walking the verifier clock by a known offset. Replace <token> with the test token and <offset_seconds> with the intended shift. python3 -c "import time,os; target=os.environ.get('FAKE_NOW'); print('override_now=', target)"
# Confirm the verifier library version and its documented default leeway, so a default change between minors is visible. Replace <module> with the JWT library's importable module name used in the verifier. python3 -c "import <module> as m; print(getattr(m, '__version__', 'unknown'))"