JWT Expiration Claims: exp, iat, and nbf Explained

4 min read

"Why did my token expire early?" and "why is this token still valid when I expected it to expire?" are two of the most common JWT debugging questions, and both usually come down to misreading one of three time-related claims. Here's what each one actually means.

The three time claims

{
  "iat": 1721606400,
  "nbf": 1721606400,
  "exp": 1721610000
}
  • iat (issued at) — when the token was created. Purely informational; nothing enforces behavior based on this claim alone.
  • nbf (not before) — the token isn't valid until this time. Rare in practice, but shows up when a token is issued in advance for future use (a scheduled access grant, for example).
  • exp (expiration) — the token stops being valid after this time. This is the one that actually gets enforced on essentially every real system, and the one people mean when they say a token "expired."

They're Unix timestamps, in seconds — not milliseconds

This is the single most common source of confusing bugs. JWT time claims are specified in seconds since the Unix epoch, but JavaScript's Date and Date.now() work in milliseconds. Feeding a JWT claim straight into new Date(claim) without multiplying by 1000 produces a date sometime in 1970, not the real expiration time:

const exp = 1721610000; // seconds

new Date(exp);           // Wrong — interpreted as milliseconds, gives a 1970 date
new Date(exp * 1000);    // Correct

Comparing against "now" correctly

function isExpired(exp) {
  const nowInSeconds = Math.floor(Date.now() / 1000);
  return nowInSeconds >= exp;
}

Convert one side or the other consistently — either bring the claim up to milliseconds, or bring Date.now() down to seconds. Mixing units silently is exactly what produces "expired 50 years ago" or "never expires" bugs that are confusing to track down because nothing actually throws an error.

Clock skew — why "expired 2 seconds ago" tokens sometimes still work

The server issuing a token and the server (or browser) checking it don't always have perfectly synchronized clocks. Many JWT libraries build in a small grace period — a few seconds to a couple of minutes — when checking exp/nbf, so a token isn't rejected purely because of a tiny clock drift between systems. If you're implementing your own check and see off-by-a-few-seconds behavior, this is usually why; it's a deliberate tolerance, not a bug.

What happens if a claim is missing

All three time claims are optional per the JWT spec. A token with no exp should be treated as non-expiring by whatever's checking it — there's no implicit default expiration. Our JWT Decoder formats whichever of these claims are actually present using your browser's local timezone (for readability), and simply omits any that are missing, rather than guessing or showing a placeholder.

Try JWT Decoder now

Inspect JWT header, payload, and signature with syntax highlighting.

Open JWT Decoder