Seconds since the epoch, and why that one word breaks tokens
exp, iat and nbf are NumericDate values: seconds since 1970-01-01 UTC, not milliseconds. exp is when a token stops being valid, iat when it was issued, nbf the earliest moment it may be used. Verifiers usually allow a small clock-skew leeway of under a minute.
Key Takeaways
- All three claims are seconds.
Date.now()in JavaScript returns milliseconds, and a millisecondexpreads as a date tens of thousands of years away — a token that never expires, by accident. - A ten-digit value is seconds; a thirteen-digit value is milliseconds. That single check catches the bug in a second.
- All three claims are optional in RFC 7519. Nothing forces an issuer to set
exp, which is why "no expiry" is a real failure mode rather than a theoretical one. - Leeway exists because the issuer's clock and the verifier's clock are never identical. Thirty to sixty seconds is enough; a few minutes is the outer limit the RFC suggests.
- Leeway is not free: it extends the token's real life by that amount, so it must stay small relative to the lifetime.
- A long
expis dangerous because a stateless verifier has no way to take the token back before that moment arrives.
Most JWT problems that reach a bug tracker are not cryptographic. They are arithmetic: a unit mistake, a clock a minute out, or a lifetime chosen without anyone deciding what it traded against. If you want the anatomy of the token itself, how to decode a JWT covers the three segments and the Base64URL layer.
What NumericDate actually is
RFC 7519 defines a single time format for JWT and calls it NumericDate. It is a JSON number giving the count of seconds from 1970-01-01T00:00:00Z until the moment in question, ignoring leap seconds — the same definition POSIX uses for "seconds since the epoch".
Four details of that definition cause almost all the trouble:
- Seconds, not milliseconds. There is no unit field and no suffix.
1788868800and1788868800000are both valid JSON numbers and both parse cleanly. Only one of them means what you intended. - A number, not a string.
"exp": 1788868800is correct;"exp": "1788868800"is not. Some libraries coerce it and strict ones reject the token, so the same token can pass on one service and fail on the next. - Leap seconds are ignored. The value is not a true count of elapsed SI seconds, so do not use
exp - iatas a precision interval. For token lifetimes it does not matter. - Non-integer values are permitted for fractions of a second, though almost nothing issues them and some parsers handle them badly. Emit integers.
The format carries no timezone, because there is nothing to carry: the epoch is defined in UTC and the number is an offset from it. Local time never enters the token, which removes the entire class of daylight-saving bugs from validation — but it also means the raw number tells a human nothing. A decoder or an epoch timestamp converter is the only practical way to read one by eye.
The three time claims at a glance
All three are registered claims in RFC 7519, and all three are optional in the base specification. Profiles built on top of JWT tighten that — OpenID Connect's ID token, for instance, requires both iat and exp — but the JWT spec itself does not.
| Claim | Meaning | Status in RFC 7519 | Typical failure when wrong or missing |
|---|---|---|---|
exp | Expiration time. The verifier MUST NOT accept the token on or after this moment. | Optional | Missing: the token is valid forever and cannot be withdrawn. In milliseconds: same result, silently. Too far out: a leaked token stays live for the whole window. |
nbf | Not before. The token MUST NOT be accepted before this moment. | Optional | **Set equal to iat: any verifier whose clock runs slightly behind rejects a brand-new token as "not yet valid". Missing:** usually harmless; the token is simply usable from issue. |
iat | Issued at. When the token was created. | Optional | Missing: you cannot enforce a maximum age, cannot detect a replayed old token, and cannot invalidate everything issued before a password change. In the future: strict verifiers reject it. |
A useful way to hold them: nbf and exp are the two ends of a validity window, enforced by the verifier. iat is provenance — not an enforcement rule by itself, but the claim that makes several enforcement rules possible.
One consequence worth stating plainly: because exp is optional, a token with no exp is not malformed. It is a perfectly valid JWT that never expires. If your verifier treats a missing exp as "nothing to check" rather than as a rejection, you have built a permanent credential without meaning to. Require exp explicitly on the verifying side.
The milliseconds bug, in numbers
This is the most common JWT time bug, and it comes from one line. [Date.now()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now) returns milliseconds since the epoch, and every JavaScript date helper works in milliseconds too. So exp: Date.now() + 3600000 looks entirely reasonable and is wrong by a factor of a thousand.
The correct form is exp: Math.floor(Date.now() / 1000) + 3600. In Python, int(time.time()) + 3600. In Go, time.Now().Add(time.Hour).Unix(). The pattern is the same everywhere: get seconds, then add seconds.
Here is what the values actually mean:
| Value in the token | Digits | Read as seconds, this is | Verdict |
|---|---|---|---|
1788868800 | 10 | 2026-09-08 12:00:00 UTC | Correct |
1788869700 | 10 | 2026-09-08 12:15:00 UTC | Correct — 15 minutes later |
1788868800000 | 13 | roughly the year 58,657 | Milliseconds bug |
2147483647 | 10 | 2038-01-19 03:14:07 UTC | The signed 32-bit ceiling |
9999999999 | 10 | 2286-11-20 17:46:39 UTC | Last ten-digit second |
A millisecond exp puts expiry about 56,000 years out. Nothing throws. No test fails. Every token your service issues is effectively permanent, and you will not find out until someone audits a token or a credential leaks and cannot be retired.
How to spot it in one glance: count the digits. Ten digits is seconds, and stays that way until 2286. Thirteen is milliseconds. Paste the token into the JWT decoder and it renders exp, iat and nbf three ways at once — raw number, UTC date, and relative time — and flags a value large enough to be milliseconds.
The 2038 row is a separate hazard. JSON numbers are doubles, so the format handles large values fine, but anything that round-trips a JWT timestamp through a signed 32-bit integer — an older database column, a C struct, an embedded device — overflows on 2038-01-19. Long-lived tokens reach that boundary before the date does.
Clock skew, and why verifiers allow leeway
The issuer stamps iat from its clock. The verifier compares exp against a different machine's clock. Those two clocks are never exactly equal. Servers synchronised over NTP are usually close — well inside a second — but "usually" is doing real work there: a VM resuming from a snapshot, a container on a drifted host, a phone with the time set manually, a laptop coming out of sleep. Any of them can be tens of seconds out.
RFC 7519 anticipates this. In the definitions of both exp and nbf it says implementers may allow some small leeway, usually no more than a few minutes, for clock skew. Most libraries expose that as a leeway or clockTolerance parameter, and most default it to zero — so the first machine that drifts starts returning intermittent 401s.
Skew shows up differently depending on which direction it runs:
| Situation | What the verifier computes | Symptom you actually see |
|---|---|---|
| Verifier clock behind the issuer | now < nbf, or now < iat | "Token not yet valid" or "iat is in the future", immediately after a successful login |
| Verifier clock ahead of the issuer | now >= exp too early | Tokens expire early; sporadic 401s clustered near the end of the token's life |
| One node in a fleet has drifted | Both, on that node only | Requests fail on roughly 1/N of calls and succeed on retry — the hardest version to diagnose |
That third row is the one that burns days. Intermittent, load-balancer-dependent 401s that vanish on retry look like a race condition and are not one.
How much leeway is sensible
Thirty to sixty seconds. That absorbs ordinary NTP drift and a resumed VM without weakening anything meaningfully. Treat a few minutes as the ceiling, and anything beyond that as a sign the real problem is an unsynchronised clock — a fixable infrastructure issue, not a token-validation one.
The constraint that decides the number: leeway extends the token's effective life. Sixty seconds on a fifteen-minute access token adds about 6.7%, which is noise. Sixty seconds on a sixty-second token doubles it. Never let a short lifetime be quietly undone by a generous tolerance.
One more trap sits here. Setting nbf equal to iat looks tidy and is very common, but it creates a claim that fails the instant any verifier is even slightly behind. If you do not need a delayed start, leave nbf out. It earns its place only when the token genuinely should not work yet: a credential minted ahead of a scheduled job, or a signed link that opens at a set time.
Choosing a lifetime: access tokens against refresh flows
The lifetime decision is one trade-off, stated once: a JWT's lifetime is the exposure window if it leaks, and shortening it costs round trips. That is the whole calculation; everything else is detail.
| Access-token lifetime | Exposure window if it leaks | Refresh calls in an 8-hour session | Notes |
|---|---|---|---|
| 1 minute | Up to 1 minute | 480 | Refresh traffic dominates; skew tolerance becomes hard to set |
| 5 minutes | Up to 5 minutes | 96 | Reasonable for high-value operations |
| 15 minutes | Up to 15 minutes | 32 | The common default, and a sensible one |
| 1 hour | Up to 1 hour | 8 | Acceptable for lower-risk APIs |
| 24 hours | Up to 24 hours | 0 | Only with a real revocation path in place |
| 30 days | Up to 30 days | 0 | Effectively a permanent credential |
The refresh-token split exists precisely to break that trade-off. The access token is a stateless JWT, verified with a signature check and nothing else, and kept short. The refresh token is long-lived, sent only to the token endpoint, and — this is the part that matters — stored server-side, so it can be looked up and deleted. It is often an opaque random string rather than a JWT, because there is no benefit in making a value the server must look up anyway self-describing.
That gives you both properties: fast stateless verification on every API call, and a revocation point hit rarely enough to stay cheap. Rotating the refresh token on each use adds detection on top — if an already-used refresh token is presented again, it was either replayed or stolen, and the right response is to invalidate the whole chain.
One pairing to avoid: a long-lived access token and a refresh token. That is the cost of both designs with the benefit of neither. If the access token lasts a day, the refresh flow is not protecting anything.
Why a long-lived token is dangerous: you cannot take it back
Be precise about the threat model, because "long tokens are insecure" on its own is not an argument.
The point of a stateless JWT is that the verifier decides using only the token and a key. No database lookup, no call to the issuer. That property is exactly what makes it fast, and exactly what removes your ability to change your mind. Once a signed token is out, its exp is a promise the verifier will keep. There is no channel to say not that one, not any more — the verifier is not asking anyone.
So the honest formulation is: **exp is not a security control, it is a damage cap.** It does not prevent theft; it bounds how long a theft stays useful. A fifteen-minute token that leaks into a log file is a fifteen-minute problem. A thirty-day token in the same log file is a thirty-day problem, and you will not know which day someone used it. The OWASP Cheat Sheet Series treats short lifetimes as the primary mitigation for exactly this reason.
This is why the cases people reach for — logout, a password change, an employee leaving, a compromised device — all fail against a stateless token. "Log out" deletes the client's copy and does nothing to a copy an attacker already has. Every workable answer reintroduces state somewhere; the JWT revocation problem works through those options properly.
One technique belongs here because it depends directly on iat. Store a single tokensValidNotBefore timestamp per user, and have the verifier reject any token whose iat is earlier than it. A password change, forced logout or suspension sets that timestamp to now, and every token already issued for that user dies at once — one cheap, cacheable lookup per request instead of a per-token denylist. It only works if iat is present, which is the practical reason to always emit it.
A verifier-side checklist
Issuers get most of the attention, but every failure above is caught on the verifying side. Work through this against your own validation path:
- Verify the signature before reading any claim. Until it checks out,
expis a number an attacker chose. A decoder that shows claims without a key — including ours — reports what the token says, not what is true. - **Reject a missing
exprather than skipping the check.** Several libraries validateexponly when present, so decide this explicitly. - Sanity-check the magnitude. If
expis more than a few years out, treat it as malformed. That one bound catches the milliseconds bug at the door, and a badly configured issuer with it. - Require the claim to be a number. A string
expmeans someone's serialisation is wrong, and that rarely stays limited to one field. - Set leeway deliberately, in one place. Not zero, not five minutes: 30–60 seconds, applied consistently. Services with differing tolerances produce failures that depend on which one you hit.
- Run NTP on every verifier, and alarm on drift. Leeway is a shock absorber, not a substitute for synchronised clocks. If you keep raising it, you are treating a symptom.
- Log rejections with the reason and both timestamps. "Expired" is not diagnosable. "Rejected:
exp1788868800, verifier now 1788868830, leeway 0" tells you in seconds whether you have an expired token or a drifted clock.
One last habit, cheap and repeatedly useful: when a service starts issuing tokens, decode one before shipping and read the dates out. Ten digits, an exp that lands minutes rather than millennia from now, an iat not in the future, and nbf either absent or genuinely ahead. Every bug in this article is visible in that ten-second check.
Frequently Asked Questions
Is JWT exp in seconds or milliseconds?
Seconds. RFC 7519 defines exp, iat and nbf as NumericDate values — seconds since 1970-01-01 UTC, ignoring leap seconds. Milliseconds are the single most common mistake, because JavaScript's Date.now() returns milliseconds and the result still parses as valid JSON. Use Math.floor(Date.now() / 1000) in JavaScript, int(time.time()) in Python, .Unix() in Go. To check an existing token, count the digits: ten digits is seconds, thirteen is milliseconds.
What happens if a JWT has no exp claim?
It never expires. exp is optional in RFC 7519, so a token without one is valid, well-formed and permanent. Many libraries validate exp only if it is present, which means the token passes validation cleanly forever. Configure your verifier to require exp and reject tokens that omit it, rather than relying on issuers to always set it.
How much clock skew leeway should I allow when verifying a JWT?
Thirty to sixty seconds is a sensible default. RFC 7519 suggests small leeway, usually no more than a few minutes. Remember that leeway extends the token's real lifetime: 60 seconds on a 15-minute token adds about 6.7%, which is negligible, but 60 seconds on a 60-second token doubles it. Keep leeway small relative to the lifetime, and fix clock drift with NTP rather than by raising the tolerance.
Why does my JWT fail with "token not yet valid" right after it is issued?
Almost always nbf set equal to iat, combined with a verifier whose clock runs slightly behind the issuer's. The token is legitimately in the future from the verifier's point of view. Two fixes: omit nbf unless the token genuinely should not work yet, and configure a small leeway. Some libraries also reject a future iat, which produces the same symptom from the same cause.
Can I extend a JWT's expiry without issuing a new token?
No. exp is inside the signed payload, so changing it invalidates the signature. That is the point — the value is a commitment the issuer made and cannot quietly revise. Extending a session means issuing a fresh token, which is exactly what a refresh flow does: a long-lived, server-stored refresh token exchanged at the token endpoint for a new short-lived access token.
Should exp be checked before or after the signature?
After. Until the signature is verified, every claim in the token is attacker-controlled input, including exp — anyone can craft a token with an expiry far in the future. Verify the signature with a pinned algorithm first, then evaluate exp, nbf, iss and aud. Standard libraries do this in the right order; hand-rolled validation is where it goes wrong.
Sources and references
RFC 7519 (datatracker.ietf.org) · `Date.now()` (developer.mozilla.org) · OWASP Cheat Sheet Series (cheatsheetseries.owasp.org). Content was reviewed against these sources as of the last-updated date above; external figures and rules may change after publication.