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 confusingToken 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 magnitude | Likely meaning |
|---|---|
| ~10 digits (~1e9) | Seconds — RFC-correct |
| ~13 digits (~1e12) | Milliseconds — common mis-issue |
| 16+ digits | Often 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
nbfwas 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
subor business fields are 19-digit integers, naiveJSON.parserounds 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
| Layer | Proves | Tools |
|---|---|---|
| TLS certificate | Server identity / chain completeness | Certificate decoder, incomplete chain guide |
| JWT | Claimed user / scopes | JWT 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 rawDate.now()intoexp. - Set
exp = iat + ttlSecondsfrom 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 acrossexpboundaries — 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
- Decode and count digits on
exp/iat/nbf: seconds or milliseconds? - Does the timeline offset look sane (issued now but “expired years ago” → wrong unit)?
- How many seconds apart are issuer and verifier
date -u? Is leeway 30–60s? - Is header
algnoneor outside your allowlist? - 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.
Related reading
- JWT explained: structure and security
- JWT signature invalid troubleshooting
- Base64 vs Base64URL: JWT segments and PEM
- Incomplete certificate chain
- Certificate decoder guide
This article is brought to you by ToolVault. More developer tools at the homepage.
Related Tools
Related Articles
Base64 vs Base64URL: Padding, JWT Segments, and PEM Line Wraps
Standard Base64 and Base64URL differ in alphabet and padding. Learn how JWT’s three segments use Base64URL, why PEM wraps at 64 characters, and how to stop decode failures when tools disagree.
Image Formats Explained: JPG, PNG, WebP, HEIC, and SVG — Which One, When, and How to Convert
A practical guide to choosing image formats: what JPG, PNG, WebP, HEIC and SVG are actually good at, when compression hurts, why transparency changes everything, and how to convert between formats without losing quality.
Online Base64 to Image Converter: Decode Base64 Strings to Image Files
Learn how to convert Base64 encoded strings back to image files. Understand Base64 decoding principles, common image MIME types, and real-world Web development use cases.