JWT expiration
exp is measured in seconds, not milliseconds. Passing Date.now() produces a token that expires around the year 51,000 — and nothing will warn you, because such a token is not expired.
By the Withuse team · Updated
The unit mistake, and why it is silent
Math.floor(Date.now() / 1000) // 1788101681 ✓ 10 digits Date.now() // 1788101681678 ✗ 13 digits
RFC 7519 defines exp, nbf and iat as NumericDate values — seconds since the Unix epoch. Every other part of JavaScript works in milliseconds, so the wrong value is the one closest to hand. Multiplying the expiry by a thousand pushes it roughly fifty thousand years out, and since validators only ask whether the token has expired, the answer is a confident no. The bug ships, and the token that should have lasted fifteen minutes is effectively permanent.
The quickest check is to count digits. A current NumericDate has ten; anything with thirteen is a millisecond value. Pasting the token into the decoder on this site makes it obvious immediately, because the expiry renders as a date far outside any plausible range.
The three claims do different jobs
| Claim | Meaning | Enforced? |
|---|---|---|
iat | Issued at | No — informational, lets you judge age |
nbf | Not valid before | Yes — reject earlier |
exp | Expires at | Yes — reject later |
All three are optional. A token with no exp is valid and never expires by itself, which is almost never the intent: a leaked token then stays useful until the signing key is rotated, an operation far heavier than waiting out a fifteen-minute lifetime. Set an expiry on everything you issue.
One thing worth keeping in view: these claims sit in the payload and are asserted by whoever produced the token. They mean something only after the signature has been checked. Reading an expiry out of a decoded token tells you what the sender claims, which is why decoding is not verifying.
Clock skew
When tokens fail intermittently right around their boundaries, the usual cause is that the issuing and verifying machines disagree about the time. A few seconds of drift is enough for a freshly minted token to look not-yet-valid, or a nearly-expired one to look expired early. RFC 7519 anticipates this and permits a small implementation-defined leeway; most libraries expose it as a configurable tolerance of a few seconds.
Check NTP on both hosts before reaching for that setting, and keep any leeway small. It exists to absorb drift, not to quietly extend token lifetimes.
You cannot extend a token, only replace it
Because exp lives inside the signed payload, editing it breaks the signature and the token is rejected. That is precisely the guarantee you want. Extending a session means issuing a new token, which is what the refresh-token pattern is for: a short-lived access token carries ordinary requests, while a longer-lived refresh token held server-side can be exchanged for a fresh one — and, crucially, revoked. A stateless access token cannot be revoked, and that asymmetry is the reason to keep its lifetime short.
Frequently asked questions
Is the exp claim in seconds or milliseconds?
Seconds. RFC 7519 defines exp, nbf and iat as NumericDate values: the number of seconds since 1970-01-01 UTC, ignoring leap seconds. JavaScript is where this goes wrong, because Date.now() returns milliseconds — a thousand times larger. The correct expression is Math.floor(Date.now() / 1000), and the giveaway is digit count: a current NumericDate has ten digits while Date.now() has thirteen. Passing the millisecond value produces an expiry roughly fifty thousand years in the future, and because the token is genuinely not expired, no validator will object. The mistake therefore ships silently. Pasting the token into a decoder makes it obvious at once, because the expiry renders as a date tens of thousands of years away rather than as a plausible one.
What happens if a JWT has no exp claim?
It never expires on its own. RFC 7519 lists every registered claim including exp as optional, so a token without one is perfectly valid and most libraries will accept it indefinitely. That is rarely what anyone intends. A leaked token with no expiry stays useful to whoever holds it until the signing key is rotated, which is a much heavier operation than waiting out a short lifetime. Set exp on every token you issue, keep access tokens short — minutes rather than days — and put the long lifetime in a refresh token you can revoke server-side instead. The asymmetry is the whole point: a stateless access token cannot be withdrawn once issued, while a refresh token held in your own storage can be.
What is the difference between exp, nbf and iat?
They mark three different moments. iat records when the token was issued and is informational: it lets a verifier judge age, but on its own it enforces nothing. nbf is the earliest moment the token may be accepted, which is useful for credentials minted ahead of time. exp is the moment after which it must be rejected. Only nbf and exp are enforcement points, and a correct verifier checks both. Note that all three are asserted by the issuer and live inside the signed payload, so they are only trustworthy once the signature has been verified — reading them from a decoded token proves nothing. A correct verifier therefore checks the signature first and the time claims second, never the other way round.
Why is my token rejected as expired when the clock looks right?
Usually clock skew between the issuing and verifying machines. A token issued on a server whose clock runs slightly ahead can appear not-yet-valid, and one verified against a clock running behind can appear expired, even though both look correct locally. RFC 7519 anticipates this and allows implementers a small leeway, typically a few seconds, and most libraries expose it as a configurable tolerance. If tokens fail intermittently around their boundaries, check NTP on both hosts before suspecting the token. Keep the leeway small — it is a tolerance for drift, not a way to extend lifetimes. A leeway large enough to mask a real clock problem will also mask a genuinely expired token.
Can I extend a JWT's expiry without reissuing it?
No. The claims sit inside the signed payload, so changing exp by even one digit invalidates the signature and the token is rejected. That is the property you are relying on — if expiry could be edited in transit it would be worthless. Extending a session therefore means issuing a new token, which is what refresh tokens exist for: a short-lived access token carries the requests, and a longer-lived refresh token stored server-side is exchanged for a fresh one. The refresh token can be revoked, which the access token cannot be, and that asymmetry is the entire design. Rotating the refresh token on each exchange adds a further benefit: if an old one is presented again, you have evidence that a copy leaked.
References
Paste a token into the JWT decoder to see its expiry as a readable date. More browser-only tools at withuse.io/tools.