JWT exp, nbf, iat: Read Token Expiry Without Math (2026)
Learn what the JWT exp, nbf, and iat claims mean, how to read token expiry without doing epoch math by hand, and the clock-skew gotchas that bite in 2026.
JWT exp, nbf, iat: Read Token Expiry Without Math
Every JSON Web Token carries its lifetime in three little numbers — exp, nbf, and iat — each a bare integer like 1780000000. No date, no timezone, no units. That design is deliberate, but it trips up almost everyone the first time a token "looks valid" yet the API keeps returning 401. This guide explains what each claim means, how to read JWT expiry without epoch math, and the clock-skew traps that still bite in 2026.
TL;DR
exp,nbf, andiatare NumericDate values: seconds since 1970-01-01 UTC.exp= expires,nbf= not valid before,iat= issued at.- Only
expandnbfgate acceptance;iatjust records the token's age. - Multiply by 1000 before comparing against JavaScript's millisecond
Date.now(). - Allow a few seconds of clock-skew leeway — never more than a couple of minutes.
What exp, nbf, and iat actually mean
These three are registered claims defined in RFC 7519, the JSON Web Token specification. They live in the payload — the middle Base64URL segment of the token — alongside whatever custom claims your app adds. All three are optional, so a token may carry none, one, or all of them.
exp (Expiration Time)
exp is the moment on or after which the token must not be accepted. Per RFC 7519, validation requires that the current time be before the exp value. Once the clock passes exp, every well-behaved verifier rejects the token, which is exactly why an "I just logged in" session can still throw a 401 an hour later.
nbf (Not Before)
nbf is the mirror image of exp: the earliest time the token may be used. The spec says the current time must be after or equal to nbf for the token to be accepted. It is handy for tokens minted ahead of time — for example, an access grant that should only activate at the start of a billing window.
iat (Issued At)
iat records when the token was created. Unlike the other two, it does not by itself reject anything — RFC 7519 notes it "can be used to determine the age of the JWT." Many systems still use it defensively: reject anything older than N minutes, or invalidate tokens issued before a password change.
| Claim | Meaning | Gates acceptance? |
|---|---|---|
exp |
Expires on/after this time | Yes — must be in the future |
nbf |
Not valid before this time | Yes — must be in the past |
iat |
Issued at this time | No — informational/age check |
Why JWT times are 10-digit numbers
The reason exp looks like 1780000000 instead of 2026-06-01T00:00:00Z is a single type definition buried in the spec — and it is the source of most expiry bugs.
Seconds since the epoch, not milliseconds
RFC 7519 defines the NumericDate type as the number of seconds from 1970-01-01T00:00:00Z UTC to the target time, ignoring leap seconds — the same "Seconds Since the Epoch" notion as POSIX. The key word is seconds. A seconds-based timestamp in 2026 is ten digits long. This is the same Unix timestamp you see in server logs; if you want a refresher on the format, our Unix timestamp explainer breaks down what those ten digits encode.
Why JavaScript trips on this
JavaScript's Date.now() and new Date().getTime() return milliseconds — thirteen digits, not ten. So a naive comparison is off by a factor of 1000 and effectively always says "not expired":
// WRONG: comparing seconds to milliseconds
if (payload.exp < Date.now()) {
// never true until the year ~58,000
}
The MDN reference for Date.now() is explicit that the value is milliseconds elapsed since the epoch. Reconcile the units in exactly one place and the rest of your logic gets simple.
Converting a NumericDate to a human date
To turn an exp into something readable, multiply by 1000 and hand it to Date:
const exp = 1780000000; // seconds
const when = new Date(exp * 1000);
console.log(when.toISOString());
// 2026-05-28T11:06:40.000Z
If you would rather not run code at all, paste the token into the iKit JWT decoder; it renders exp, nbf, and iat as both the raw integer and a formatted local time, so you can see at a glance whether a token is stale.
How to check if a JWT is expired without doing the math
The fastest path is to never compute anything by hand. A JWT is not encrypted — the payload is just Base64URL-encoded JSON — so any decoder can show you the claims instantly.
Decode the payload, don't trust your eyeballs
A decoded payload looks like this:
{
"sub": "user_8842",
"iat": 1780000000,
"nbf": 1780000000,
"exp": 1780003600
}
Read it as: issued at iat, usable from nbf, dead at exp. Here exp − iat is 3600, so this token lives for one hour. A good decoder converts each of those integers to a wall-clock time and flags the token red once exp is in the past — no mental arithmetic required.
Reading exp / nbf / iat in 3 seconds
When you are debugging an auth failure, scan the three claims in order. Is nbf in the future? The token is not active yet. Is exp in the past? It is stale — refresh it. Is iat suspiciously old? Something is replaying a cached token. This three-claim glance resolves the overwhelming majority of "why is this 401" tickets, which is the same playbook behind our guide on decoding a JWT to debug a 401.
Computing time remaining in code
If you need the value programmatically — say, to refresh a token 60 seconds before it dies — do the unit conversion once and work in seconds:
function secondsUntilExpiry(payload) {
const now = Math.floor(Date.now() / 1000);
return (payload.exp ?? 0) - now;
}
const left = secondsUntilExpiry(payload);
const expired = left <= 0;
Math.floor(Date.now() / 1000) is the canonical way to get the current NumericDate in JavaScript: take milliseconds, divide by 1000, floor it.
How to calculate JWT expiry in JavaScript
Decoding is enough for inspection, but if your app gates UI on token state, wrap the logic so a unit mistake can only happen once.
A small helper for exp and remaining seconds
function tokenStatus(payload, leeway = 5) {
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp;
const nbf = payload.nbf ?? 0;
if (nbf && now + leeway < nbf) return "not-yet";
if (exp && now - leeway >= exp) return "expired";
return "active";
}
Note that this only reads the claims — it does not verify the signature. Never make a security decision on an unverified token (more on that below).
Handling nbf (token not valid yet)
nbf failures are rarer than exp failures, but they are baffling when they happen: a brand-new token gets rejected. Usually the issuer's clock is slightly ahead of the verifier's, so nbf lands a second or two in the verifier's future. The fix is the same leeway you apply to exp.
Clock skew and leeway: why a few seconds matter
RFC 7519 anticipates this. For both exp and nbf it says implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew." In practice a handful of seconds is plenty — common library defaults sit around 5 to 60 seconds. Anything beyond a couple of minutes hides a real infrastructure problem (run NTP) and widens the replay window, so keep leeway as small as your fleet's clocks allow. The broader JWT Best Current Practices, RFC 8725, reinforces the principle: always validate claims before processing a token, and keep the acceptance window tight.
Common mistakes when reading JWT expiry
A few errors show up over and over. Watch for these:
- Treating
expas milliseconds. It is seconds. Multiply by 1000 only when handing it toDate, and divideDate.now()by 1000 everywhere else. - Forgetting
expis optional. A token with noexpnever expires on its own. If your code doespayload.exp < nowandexpisundefined, the comparison isfalseand you silently accept a forever-token. - Assuming the client check is the real check. Decoding in the browser is for UX. Expiry is enforced server-side, after signature verification.
- Ignoring
nbf. A futurenbfrejects a token that otherwise looks perfect. Always read all three claims, not justexp.
Why a token can be expired even when it "looks fine"
The classic case: exp reads as a future local time in your decoder, but the API still rejects it. Two usual suspects — your machine's clock is wrong, or you are reading the local-time rendering while the server compares in UTC. Because NumericDate is defined in UTC, always reconcile in UTC and confirm the underlying integer, not the prettified string.
Don't confuse decoding with verifying
Reading exp tells you whether a token is stale. It tells you nothing about whether the token is authentic. Anyone can edit a payload and re-encode it; only signature verification — using the issuer's key — proves the claims are trustworthy. If you are choosing where to inspect tokens during development, a fully client-side tool keeps the token off third-party servers entirely, the same privacy argument we make in JWT Decoder vs jwt.io.
References
- RFC 7519 — JSON Web Token (JWT) — primary spec; cited for the exp/nbf/iat definitions, validation rules, and the NumericDate (seconds-since-epoch) type.
- RFC 8725 — JSON Web Token Best Current Practices — used for the "validate claims before processing" guidance and tight acceptance windows.
- MDN: Date.now() — confirms Date.now() returns milliseconds, the source of the ×1000 unit bug.
Related on iKit
- Inside a JWT: a field-by-field guide to standard claims — the full registered-claim set (sub, aud, iss…) that surrounds exp, nbf, and iat.
- Decode a JWT to debug a 401 Unauthorized — applies the three-claim scan to the most common auth failure.
- How to decode a JWT — Auth0 & Firebase examples — how real providers populate exp and iat in their access tokens.
- Build your own JWT in 5 lines of JavaScript — see exactly how exp gets written into a token at signing time.
- JWT Decoder vs jwt.io: privacy and features compared — why a client-side decoder keeps your tokens off other people's servers.
- HS256 vs RS256: which JWT algorithm should you pick — the signing choice that decides who can forge an exp you'd otherwise trust.
- The alg: none JWT vulnerability — a reminder that reading claims is not the same as verifying them.
Related posts
Why Your JavaScript Timestamp Is 1000× Your Backend's (2026)
Your JavaScript timestamp is in milliseconds while most backends use seconds, so it looks 1000× too big. Here is why, and how to convert it safely.
RFC 3986 in 2026: What Counts as an Unreserved Character
RFC 3986 names just 66 characters as unreserved — and most encoders mishandle the rest of printable ASCII. Here's what each one actually is, and why it matters.
Double-Encoded URLs: How to Spot and Fix Them (2026)
A double-encoded URL is when a value runs through encodeURIComponent twice. Here's how to spot %2520 in seconds and fix the API bug it caused.