Skip to content
Encoding2026-10-11Begin4 min read

JWT exp/nbf Clock Skew: Seconds vs Milliseconds, Leeway, and “Expired on Issue”

Symptom: signature OK, time wrong

A frequent 401 pattern in integration tests:

  • The JWT decoder shows a clean payload and a green HS256 check
  • The API still returns Token expired, jwt expired, or the confusing Token used before issued / nbf

The secret is often fine. NumericDate semantics or clocks are not. When TLS is healthy (certificates are a separate story) but auth fails, start here.

Units in RFC 7519

Standard claims exp / iat / nbf are NumericDate = seconds since 1970-01-01 UTC, not JavaScript milliseconds.

Rough magnitudeLikely meaning
~10 digits (~1e9)Seconds — RFC-correct
~13 digits (~1e12)Milliseconds — common mis-issue
16+ digitsOften ns / snowflake IDs, not a standard NumericDate

Issuers that write Date.now() (ms) into exp while verifiers compare in seconds produce absurd relative times. The on-site decoder applies a display-time heuristic for obvious millisecond magnitudes and shows UTC plus offsets such as “expired 2 hours ago”. Production libraries should still treat seconds as law and fix the issuer.

Three clock traps

1. Distributed drift

A few seconds of skew across Kubernetes nodes or regions is normal. Verifiers should allow 30–60 seconds of leeway: accept when now is in [nbf - leeway, exp + leeway]. Leeway absorbs NTP jitter; it is not a product feature to “stretch” expired sessions. Multi-minute leeway widens replay windows.

2. nbf in the future

Token used before issued usually means:

  • The issuer clock is ahead of the verifier, or
  • nbf was set to a round wall-clock time and traffic arrived a few seconds early

Compare date -u on both sides and check NTP before blaming algorithms.

3. Trusting frontend exp alone

Browsers may render “session remaining” from local clocks, but authorization must be decided server-side (signature + exp/nbf + aud/iss + revocation). A green frontend exp can still hide a revoked jti.

Alongside alg=none and large integer claims

  • alg=none: unsigned tokens. Historical libraries that accepted none let attackers rewrite the header and drop the signature. A red warning on the decoder is intentional; production needs an algorithm allowlist. See the JWT decode guide.
  • Snowflake / huge integers: if sub or business fields are 19-digit integers, naive JSON.parse rounds them. This site’s decode path displays them losslessly; your APIs should use strings or BigInt strategies so IDs do not silently mutate in logs.

Do not mix this up with HTTPS certificates

LayerProvesTools
TLS certificateServer identity / chain completenessCertificate decoder, incomplete chain guide
JWTClaimed user / scopesJWT decoder, this article

When handshake failures and HTTP 401s appear together, reproduce them separately before changing configs.

Issuer-side habits that prevent the ticket

Most “clock” incidents are really issuance bugs that look like infrastructure:

  • Prefer Math.floor(Date.now() / 1000) (or language equivalents) whenever you write NumericDate fields — never paste raw Date.now() into exp.
  • Set exp = iat + ttlSeconds from one clock reading so the three claims cannot disagree with each other by accident.
  • Log both the raw claim integers and an ISO-8601 rendering in auth failures; support engineers should not have to decode Base64 under pressure.
  • In tests, freeze time (sinon / freezegun / timecop) instead of sleeping across exp boundaries — sleeps flake in CI and hide unit mistakes.

If your stack speaks milliseconds internally, convert at the JWT boundary once and document that boundary in the service README. Ambiguity at the boundary is how 13-digit exp values survive code review.

Five-minute checklist

  1. Decode and count digits on exp/iat/nbf: seconds or milliseconds?
  2. Does the timeline offset look sane (issued now but “expired years ago” → wrong unit)?
  3. How many seconds apart are issuer and verifier date -u? Is leeway 30–60s?
  4. Is header alg none or outside your allowlist?
  5. Is TLS actually succeeding (certificate issues are a different ticket)?

Implementer's note

The on-site timeline formats standard time claims as UTC + relative offset; Copy stays in the toolbar above so the UX does not block the primary action. Millisecond normalization is a display heuristic, not a rewrite of your issuance code. HS256 verify runs locally in the browser; RS256/JWKS fetch is not built in — verify those server-side. Same privacy boundary as the certificate decoder: material is not uploaded.

This article is brought to you by ToolVault. More developer tools at the homepage.