JWT Expiry Claims Explained: exp, iat, and nbf (With Node.js Examples)
Every JSON Web Token carries a handful of time-related claims that decide when it is valid, when it stops working, and when it starts working. Get them right and your authentication feels seamless. Get them wrong and you ship one of the most common bugs in web development: tokens that expire instantly, tokens that never expire, or a login that breaks only for users whose laptop clock is two minutes fast. This guide explains exp, iat, and nbf in plain language, shows how to handle them with the Node.js jsonwebtoken library, and covers the millisecond-versus-second trap that catches almost every developer at least once.
The Three Time Claims: exp, iat, and nbf
RFC 7519, the JWT specification, defines three registered claims that deal with time. All three hold a NumericDate value — a unix timestamp counted in seconds (not milliseconds) since 1 January 1970 UTC.
- exp (expiration time) — the moment after which the token must be rejected. A verifier compares the current time against exp and fails validation once the current time is past it.
- iat (issued at) — the moment the token was created. Useful for auditing, for computing token age, and for invalidating all tokens issued before a certain event (for example, a password change).
- nbf (not before) — the moment before which the token must not be accepted. Rarely set manually, but handy for tokens that should activate in the future, such as a scheduled access grant.
A decoded payload with all three claims looks like this:
{
"sub": "user_9182",
"role": "admin",
"iat": 1784197800, // issued at: 2026-07-16 10:30:00 UTC
"nbf": 1784197800, // valid from: same moment it was issued
"exp": 1784198700 // expires: 15 minutes later
}You can paste any token into a JWT decoder to see these claims and check whether the token has already expired — the decoding happens entirely in your browser.
Setting and Verifying Time Claims in Node.js
With the popular jsonwebtoken library you rarely set exp by hand. The expiresIn option computes it for you, and iat is added automatically:
const jwt = require('jsonwebtoken');
// Sign: expiresIn accepts '15m', '1h', '7d', or a number of SECONDS
const token = jwt.sign(
{ sub: 'user_9182', role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '15m', notBefore: 0 } // exp = now + 900s, nbf = now
);
// Verify: exp and nbf are checked automatically
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
console.log('Valid until', new Date(payload.exp * 1000).toISOString());
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('Token expired at', err.expiredAt);
}
}Note the multiplication by 1000 when converting exp back to a JavaScript Date. That single detail is where most JWT time bugs are born, so let us look at it directly.
The Milliseconds vs Seconds Bug
JWT claims use unix seconds. JavaScript's Date.now() returns unix milliseconds. Mix the two and you get spectacular failures in both directions:
// BUG 1: token that "never expires"
// Date.now() is ~1784197800000 (ms). Interpreted as seconds,
// that is the year 58514. The check passes forever.
const bad = jwt.sign(
{ sub: 'u1', exp: Date.now() + 900 }, // WRONG: ms + seconds
secret
);
// BUG 2: client thinks a fresh token is already expired
// payload.exp is in seconds; comparing it to ms makes
// every token look ancient.
if (payload.exp < Date.now()) { // WRONG comparison
logout(); // fires immediately
}
// CORRECT: convert one side
const nowInSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowInSeconds) logout();
// or convert exp to ms
if (payload.exp * 1000 < Date.now()) logout();A reliable habit: whenever you touch exp, iat, or nbf manually, immediately ask "seconds or milliseconds?" and add the conversion in the same line. Better still, let the library compute exp via expiresIn and never write the raw math yourself.
Clock Skew and Leeway
Your auth server and your API servers do not share a clock. Even with NTP, machines drift by a few seconds; user devices can be off by minutes. This causes two classic symptoms:
- A freshly issued token is rejected with "jwt not active" because the verifying server's clock is slightly behind the issuing server (nbf appears to be in the future).
- A token is rejected as expired a few seconds before its real exp because the verifier's clock runs fast.
The fix is leeway (also called clock tolerance): a small grace window applied to time-claim checks. In jsonwebtoken it is the clockTolerance option:
// Accept up to 30 seconds of clock drift for exp and nbf checks
const payload = jwt.verify(token, process.env.JWT_SECRET, {
clockTolerance: 30 // seconds
});Keep leeway small — 30 to 60 seconds is typical. A large leeway silently extends every token's lifetime, which defeats the point of a short exp.
Choosing a Token Lifetime Strategy
There is a permanent tension in picking exp: long lifetimes are convenient but risky (a stolen token works until it expires, and JWTs are hard to revoke), while short lifetimes are safe but would log users out constantly. The standard resolution is a two-token design:
- Access token — short-lived (5 to 15 minutes). Sent with every API request. If it leaks, the damage window is minutes.
- Refresh token — long-lived (days to weeks). Stored more carefully (for example in an httpOnly cookie), sent only to one dedicated endpoint, and used solely to mint a new access token when the old one expires.
Production systems add refresh token rotation: every time a refresh token is used, the server issues a brand-new refresh token and invalidates the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt fails — and that reuse of an already-rotated token is a strong signal of theft, at which point the server revokes the whole session family.
- Internal admin dashboard: access token 5m, refresh 8h (one working day).
- Consumer web app: access token 15m, refresh 7 to 30 days with rotation.
- Machine-to-machine API: access token 30 to 60m, no refresh token — clients simply request a new token with their credentials.
Frequently Asked Questions
JWT exp is always in unix seconds, as defined by RFC 7519. JavaScript Date.now() returns milliseconds, so divide by 1000 before comparing. Mixing the two units is one of the most common JWT bugs.
exp (expiration time) is the moment after which the token must be rejected. iat (issued at) records when the token was created. nbf (not before) is the moment before which the token must not be accepted. All three are unix timestamps in seconds.
Most production systems use short-lived access tokens of 5 to 15 minutes, paired with a longer-lived refresh token. Short expiry limits the damage window if a token leaks, while refresh tokens keep the user logged in.