JWT exp, iat and nbf: how token expiry actually works.

Three numeric claims decide whether a JSON Web Token is alive. They are simple, they are in seconds, and the ways they go wrong are always the same.

Published 2026-09-15 · 5 min read · Kirk Diamond

The three claims

ClaimNameRule
iatIssued atWhen the token was created. Informational; verifiers do not have to check it, though some reject tokens "issued in the future".
nbfNot beforeThe token must not be accepted before this time. Optional; rarely used outside scheduled access.
expExpirationThe token must not be accepted at or after this time. The one everyone uses and the one that generates the support tickets.

All three are NumericDate values: the number of seconds since 1970-01-01T00:00:00Z, as a JSON number. Not milliseconds. Not an ISO string. The JWT decoder renders them as dates with how long ago or ahead they are, so a token that has expired is obvious. If you need to convert a value by hand, the Unix timestamp converter does it.

How a verifier checks them

Roughly: now < exp and now >= nbf, where now is the verifier's clock in Unix seconds. RFC 7519 allows "some small leeway, usually no more than a few minutes, to account for clock skew". Most libraries expose this as a leeway or clockTolerance option, defaulting to zero. A token is not "almost valid"; it is valid or it is not, and one second past exp is rejected.

The important thing about that check is who is doing it and with what clock. The issuer wrote exp using its clock; the verifier compares it to its own. Neither party trusts the other's clock, and they do not have to agree with each other, only with reality.

Why a token decodes fine but is "expired"

Decoding is just Base64url plus JSON. Nothing in the decode step knows or cares about time. A token from 2019 decodes exactly as well as one issued a second ago. Expiry is a policy applied by the verifier after decoding, so seeing the claims in a decoder tells you nothing about whether a server will accept the token; you have to compare exp to now yourself. The decoder on this site does that comparison and labels the result EXPIRED or valid.

The failure modes

Clock skew

The server that issued the token is thirty seconds ahead of the server that verifies it. A token with a 60-second lifetime is half gone before it is used; a freshly issued token with nbf = iat is "not yet valid" for thirty seconds. Symptoms: intermittent 401s that go away on retry, failures that only happen on one instance in a pool. Fix the clocks (NTP on every host, and check containers inherit the host clock), then set a small leeway of 30 to 60 seconds as insurance. Never fix skew by setting a leeway of hours.

Milliseconds instead of seconds

A library writes exp using Date.now() without dividing by 1000. The value is 13 digits, the verifier reads it as seconds, and the token is valid until roughly the year 55,000. Or the reverse: a verifier compares seconds against a millisecond clock and every token is expired before it is issued. Thirteen digits in a time claim is always a bug. The decoder shows the raw value; if it is over 10 digits, that is the problem.

Time zones

Unix seconds have no time zone, so there is nothing to get wrong in the token itself. The bug is upstream: a server whose local time is set to a zone but whose clock is set as if UTC, or code that builds exp from a local datetime and treats it as UTC. The result is a token that is one time zone offset too long or too short. Compare iat to the actual current time; if they differ by a round number of hours, this is it.

Lifetime too long or too short

A token is a bearer credential: anyone holding it can use it until exp. Access tokens should live minutes (5 to 60), refresh tokens hours to days with rotation and revocation. A token with exp - iat of a year is a password that cannot be changed. Conversely a 60-second lifetime with no refresh flow produces a stream of 401s from any client that pauses.

No exp at all

exp is optional in the RFC. A token without it never expires. Some libraries accept this silently; configure yours to require it (jwt.verify(token, key, { maxAge: '1h' }) in jsonwebtoken, options={"require": ["exp"]} in PyJWT).

Debugging "token is expired"

  1. Decode the token and read exp and iat. Are they 10 digits? Is iat close to now on your machine?
  2. Compute exp - iat. Is that the lifetime you configured? If not, the issuer is wrong.
  3. Check the verifier's clock: date -u on the box, or hit /tools/headers from it and compare the Date response header to its own time.
  4. Check the issuer's clock the same way.
  5. If both clocks are right and the lifetime is right, the token really is old. Look for a client caching tokens beyond their lifetime, or a retry queue replaying an old request.

Related claims

jti gives a token a unique ID so it can be revoked before exp by keeping a denylist until it would have expired anyway. auth_time (OpenID Connect) records when the user actually authenticated, as opposed to when this particular token was minted, so a relying party can demand a fresh login for sensitive actions regardless of exp.

All guides · All tools