All posts

JWT exp and iat claims explained

What JWT exp, iat and nbf really mean: NumericDate seconds, clock-skew leeway, and the bugs behind tokens that expire early or never, with worked conversions.

By Last reviewed 6 min readJWTTimeDebugging

JWTs carry time in three claims, iat, nbf and exp. Nearly every "token expired" ticket that isn't a genuine expiry comes down to one of three causes: seconds mixed up with milliseconds, local time leaking into what should be a UTC number, or two servers whose clocks disagree.

To see what a token actually says, paste it into the JWT Decoder. It shows each time claim as a UTC date with a relative label such as "2d ago", plus an expiry badge. This guide explains how to read those numbers and how to fix the bugs behind them.

NumericDate: seconds since 1970, in UTC

RFC 7519 defines all three claims as a NumericDate: the number of seconds from 1970-01-01T00:00:00Z to the moment in question, ignoring leap seconds. Two consequences follow.

  • It is seconds, not milliseconds. Current values have 10 digits and will until the year 2286. A 13-digit value is milliseconds, and in a JWT that is a bug.
  • It has no time zone. A NumericDate is an instant. "06:00 in New York" and "15:30 in India" can be the same number; time zones only appear when you format it for display.

Producing one correctly in common languages:

// JavaScript: Date.now() is milliseconds, so divide
const now = Math.floor(Date.now() / 1000);
const exp = now + 15 * 60;              // 15 minutes from now

# Python
import time
now = int(time.time())

// Java
long now = Instant.now().getEpochSecond();

# Shell
date +%s

The seconds vs milliseconds guide covers which platforms and APIs return which unit.

What exp, nbf and iat mean exactly

  • exp: the token must not be accepted on or after this time. The check is now < exp, so at now == exp the token has already expired.
  • nbf: the token must not be accepted before this time. The check is now >= nbf. Issuers often set it equal to iat, or leave it out.
  • iat: when the token was issued. The spec only says it can be used to work out the token's age; on its own it doesn't make a token invalid. Libraries can enforce a maximum age from it, such as the maxAge option in Node's jsonwebtoken.

A token's lifetime is exp - iat. Take a token with "iat": 1792486800 and "exp": 1792490400:

exp - iat = 1792490400 - 1792486800 = 3600 seconds = 1 hour

iat 1792486800  →  2026-10-20T09:00:00Z
exp 1792490400  →  2026-10-20T10:00:00Z
                   = 06:00 EDT in New York
                   = 11:00 BST in London
                   = 15:30 IST in India

$ date -u -r 1792490400          # macOS
Tue Oct 20 10:00:00 UTC 2026
$ date -u -d @1792490400         # Linux (GNU date)

> new Date(1792490400 * 1000).toISOString()
'2026-10-20T10:00:00.000Z'
> new Date(1792490400).toISOString()      // forgot * 1000
'1970-01-21T17:54:50.400Z'

That last line is worth memorising: a date in January 1970 means a seconds value was read as milliseconds. The timestamp converter shows UTC, your local zone and another zone of your choice side by side, which settles "what time was that for the user in Berlin?" questions quickly.

Bug 1: milliseconds in exp

The issuer writes the expiry in JavaScript milliseconds:

// Wrong: Date.now() is already in milliseconds
const payload = { sub: "user_8f3a2c", exp: Date.now() + 60 * 60 * 1000 };
// exp: 1792490400000

Every verifier reads that number as seconds, which puts the expiry in the year 58771. The token effectively never expires and nothing fails, so the bug can sit in production for a long time. Paste such a token into the JWT Decoder and the symptom is obvious: exp shows as +058771-10-05 16:00:00 UTC and the badge counts down tens of millions of days.

To confirm what the issuer meant, paste 1792490400000 into the timestamp converter. It reads values above 10¹² as milliseconds, says so, and gives 2026-10-20T10:00:00Z: one hour after iat. Fix it at the source with Math.floor(Date.now() / 1000) + 3600, and add a test asserting that exp - iat equals the lifetime you intended.

Bug 2: comparing seconds with Date.now()

The mirror image happens on the consumer side, usually in front-end code deciding whether to refresh a token:

// Wrong: exp is seconds, Date.now() is milliseconds
if (claims.exp < Date.now()) refreshToken();    // always true

// Right: compare in the same unit
if (claims.exp * 1000 <= Date.now()) refreshToken();

A value around 1.79 billion is always smaller than one around 1.79 trillion, so every token looks expired and the app refreshes on every request. If your logs show a burst of refresh calls right after login, check this first.

Bug 3: local time sneaking into a UTC number

Python's datetime.utcnow() returns a naive datetime: the UTC wall-clock time with no zone attached. Calling .timestamp() on a naive datetime treats it as local time. On a UTC server that happens to give the right answer, so the bug passes CI and then appears on a developer's laptop:

from datetime import datetime, timezone

# Wrong: a naive "UTC" datetime gets interpreted as local time
iat = int(datetime.utcnow().timestamp())
#   on a machine in India (UTC+5:30): 19,800 seconds too early
#   on a machine in New York in October (EDT, UTC-4): 14,400 seconds too late

# Right
iat = int(datetime.now(timezone.utc).timestamp())

With a one-hour lifetime, the machine in India mints tokens that were "issued" five and a half hours ago and are already expired. The New York machine mints tokens whose nbf is four hours in the future, so verifiers reject them as not yet valid. Python 3.12 deprecated utcnow() and recommends datetime.now(timezone.utc) instead.

JavaScript has a cousin: new Date("2026-10-20T10:00:00") without a Z or offset is parsed as local time, while the date-only form "2026-10-20" is parsed as UTC. When you set an expiry from a human-readable date, include the offset. The JWT Builder labels its exp and nbf pickers "your local time" and converts them to UTC seconds for you. For more patterns, see how to debug timezone errors in APIs.

Clock skew and leeway

The issuer stamps iat and nbf with its own clock; the verifier checks them against its own. When the two disagree:

  • Verifier behind the issuer, say by 30 seconds: a freshly issued token's nbf is in the verifier's future, so the first request after login fails as "not yet valid" and the same token works half a minute later.
  • Verifier ahead of the issuer, say by 2 minutes: tokens expire 2 minutes early. For a 5-minute service-to-service token, that is 40% of its life.

RFC 7519 allows a small leeway, "usually no more than a few minutes", for exactly this. Libraries expose it in seconds:

// Node.js, jsonwebtoken
jwt.verify(token, key, { algorithms: ["HS256"], clockTolerance: 30 });

# Python, PyJWT
jwt.decode(token, key, algorithms=["HS256"], leeway=30)

Keep it to tens of seconds and fix the clocks themselves with NTP; leeway hides skew, it doesn't cure it. One more clock to remember: the JWT Decoder judges expiry against your computer's clock, so if your laptop's time is wrong, its badge will be too.

Next steps

Decode a token from your own environment in the JWT Decoder and check that exp - iat matches the lifetime you configured. If a date looks wrong, run the raw number through the timestamp converter, and read Unix timestamps vs ISO 8601 before choosing a time format for your own APIs.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.