Skip to main content

JWT Expired: Understanding exp, Unix Time, and Clock Skew

Before assuming the token is stale, read the exp claim itself. Two mistakes account for a large share of these errors and neither is an expired token: a timestamp written in milliseconds, and two machines whose clocks disagree.

The short answer

Decode the token and read exp. If it is a 13-digit number the timestamp is in milliseconds and the token was never going to work. If it is barely in the past, the two machines disagree about the time. Only if it is properly in the past has the token actually expired.

exp, iat and nbf are NumericDate values: seconds since 1970-01-01T00:00:00Z. Not milliseconds, and not an ISO string.

Four causes, told apart by the claim itself

CauseWhat you seeWhat to do
The token really has expiredexp is in the past, by an amount that matches the token lifetime.Request a new token. If it expires sooner than expected, look at the issuer’s lifetime setting.
exp was written in millisecondsexp is a 13-digit number, and the token appears to expire in the year 58000.NumericDate is seconds. Dividing a Date.now() value by 1000 is the usual fix at the issuer.
Clock skew between issuer and verifierexp is barely in the past, or nbf is barely in the future, by seconds rather than minutes.Compare the two machines’ clocks. Most libraries accept a small tolerance, commonly 30 to 60 seconds.
nbf has not arrived yetThe error mentions "not before" rather than expiry.The token is valid later, not now. Usually also clock skew.

Seconds, not milliseconds

The most common issuer bug. Date.now() returns milliseconds, and a NumericDate is seconds — so forgetting to divide produces a value a thousand times too large:

{ "exp": 1786648710000 }   <- year 58000, effectively never expires
{ "exp": 1786648710 }      <- what was meant

Confusingly, this often does not fail immediately: a library checking whether exp is in the future says yes, so the token works until something stricter rejects it. The decoder flags a value this large rather than rendering a date far in the future.

Clock skew

If exp is in the past by seconds rather than by the token’s lifetime, the token probably did not expire — the verifier’s clock is ahead of the issuer’s. The same cause shows up as nbf being just in the future.

Most libraries accept a tolerance, commonly 30 to 60 seconds. That is a sensible allowance for genuine drift and a poor substitute for synchronised clocks — if you need minutes of tolerance, the clocks are the problem.

What reading exp does not tell you

Reading exp tells you what the token claims about its own lifetime. It does not tell you the token is genuine: the signature is what establishes that, and decoding never checks it. A token whose exp is in the future can still be forged, revoked, or issued by the wrong party.

Read the claim

Paste the token into the JWT Decoder and it renders exp, iat and nbf as UTC timestamps, and says whether exp is before or after the clock on your device.

That comparison is exactly what it sounds like and nothing more. It uses your device’s clock, not the server’s, and it does not verify the signature — so it cannot tell you a token would be accepted. Decoding runs in your browser; the token is not uploaded.

Related

For what the other claims mean and how the three segments fit together, see what is actually inside a JWT. If the token will not decode at all, that is a different problem — why a JWT will not decode covers segments and encoding.

Measured with

  • DataToolsHQ JWT Decoderbuilt-in time-claim rendering