By AndyPublished
JWT Expiration Time: The exp, iat and nbf Claims, Units and Best Practice
exp claim: a number of seconds since 1970-01-01T00:00:00Z (a Unix timestamp, which RFC 7519 calls a NumericDate). A verifier must reject the token once the current time reaches that value. iat records when the token was issued andnbf says when it becomes valid, both in the same format. The most common bugs are unit mix-ups: milliseconds where seconds were expected, or a library option like expiresIninterpreted differently than you assumed. The converter below turns any exp oriat value, or a whole token, into a readable date.Runs entirely in your browser. Nothing is sent anywhere.
JWT exp Claim Format
RFC 7519 §2 defines NumericDate as the number of seconds from the epoch, ignoring leap seconds, and allows non-integer values. In practice every mainstream issuer uses an integer, and some libraries reject fractions, so stick to whole seconds. A current value is ten digits long:
{
"sub": "user_8412",
"iat": 1790283251,
"nbf": 1790283251,
"exp": 1790284151
}Here the token lives for 900 seconds, or 15 minutes (exp - iat). If you see a thirteen-digit value, it is milliseconds: someone wrote Date.now() instead ofMath.floor(Date.now() / 1000), and the token will not expire for tens of thousands of years. Treat that as a bug in the issuer, not something to accommodate in the verifier.
JWT iat vs exp vs nbf
| Claim | Name | Defined in | How verifiers use it |
|---|---|---|---|
| exp | Expiration time | RFC 7519 §4.1.4 | Reject if now is at or after exp (minus leeway) |
| nbf | Not before | RFC 7519 §4.1.5 | Reject if now is before nbf (plus leeway) |
| iat | Issued at | RFC 7519 §4.1.6 | Informational; optionally enforce a maximum age |
All three are optional in RFC 7519, but exp should be treated as required for anything used as a credential: a token without it is valid forever unless you build revocation. RFC 9068 makes bothexp and iat required in JWT access tokens.nbf is mostly useful for tokens issued ahead of time; many issuers set it equal toiat. For the other registered claims, see JWT claims explained.
JWT exp to Datetime: Converting in Code
Multiply by 1,000 in JavaScript, pass seconds straight through in Python, and prefix with @ in GNU date:
// JavaScript new Date(1767225600 * 1000).toISOString(); // '2026-01-01T00:00:00.000Z' # Python from datetime import datetime, timezone datetime.fromtimestamp(1767225600, tz=timezone.utc).isoformat() # '2026-01-01T00:00:00+00:00' # bash, Linux (GNU coreutils) date -u -d @1767225600 # Thu Jan 1 00:00:00 UTC 2026 # bash, macOS / BSD date -u -r 1767225600
Note that GNU date -r means "modification time of this file", so the BSD form fails on Linux with a "No such file or directory" error. In Python, always pass tz=timezone.utc; without it you get a naive local time that is easy to compare wrongly. The jwtdecode.app decoder shows exp, iat and nbf as absolute and relative times with an active or expired badge, decoded locally in the browser. For decoding the whole token on the command line, see decoding a JWT from the command line.
JWT expiresIn Units by Library
Libraries let you express lifetime as a duration and compute exp for you. The units are where things go wrong.
| Library | Option | Units |
|---|---|---|
| jsonwebtoken (Node) | expiresIn | Number = seconds from now. String = vercel/ms timespan ("15m", "1h", "7d"). A bare numeric string such as "60" is read as milliseconds. |
| jose (Node, browser) | setExpirationTime() | Number = absolute NumericDate. String = relative ("15m", "2h"). Date object = absolute. |
| PyJWT (Python) | exp in payload | int NumericDate or timezone-aware datetime; datetimes are converted to int seconds. |
| OAuth 2.0 token response | expires_in | Seconds from issuance (RFC 6749 §5.1). Not a timestamp. |
jsonwebtoken expiresIn
import jwt from 'jsonwebtoken';
jwt.sign(claims, secret, { expiresIn: 900 }); // 900 seconds
jwt.sign(claims, secret, { expiresIn: '15m' }); // 900 seconds
jwt.sign(claims, secret, { expiresIn: '1h' }); // 3600 seconds
jwt.sign(claims, secret, { expiresIn: '7d' }); // 604800 seconds
jwt.sign(claims, secret, { expiresIn: '60' }); // 60 ms -> exp === iat (!)The last line is the trap. String values go through the ms package, where a unitless string means milliseconds, so '60' rounds down to zero seconds and the token is born expired. This usually happens when the lifetime comes from an environment variable, which is always a string; wrap it in Number() or include a unit. An unparseable string such as'1 fortnight' throws "expiresIn" should be a number of seconds or string representing a timespan.
jose setExpirationTime
import { SignJWT } from 'jose';
await new SignJWT(claims)
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('15m') // relative
// .setExpirationTime(1790284151) // a NUMBER is an absolute timestamp
.sign(privateKey);The same number means different things in the two Node libraries: 900 in jsonwebtoken is fifteen minutes from now, while 900 in jose is 00:15 on 1 January 1970, so the token is already expired.
PyJWT exp
import jwt
from datetime import datetime, timedelta, timezone
now = datetime.now(tz=timezone.utc)
token = jwt.encode(
{"sub": "user_8412", "iat": now, "exp": now + timedelta(minutes=15)},
key, algorithm="HS256",
)
# PyJWT converts both datetimes to integer seconds in the payload.OAuth expires_in vs the exp Claim
The expires_in field in an OAuth 2.0 token response (RFC 6749 §5.1) is the access token's lifetime in seconds from now, not a timestamp. A response of "expires_in": 3600means one hour. When the access token is a JWT, its exp should agree, but clients are meant to treat access tokens as opaque: schedule refreshes from expires_in, computed against your own clock when the response arrives, rather than parsing the token. The refresh token pattern covers the refresh side.
JWT Expiry Check and Clock Skew
Every serious library checks exp and nbf during verification. Do not reimplement it, but do configure a small leeway, because the issuer's clock and yours will never agree exactly:
// jsonwebtoken: seconds
jwt.verify(token, key, { algorithms: ['RS256'], clockTolerance: 30 });
// jose: seconds or a duration string
await jwtVerify(token, JWKS, { clockTolerance: '30s' });
# PyJWT: seconds or timedelta
jwt.decode(token, key, algorithms=["RS256"], leeway=30)Keep the leeway to tens of seconds. If you need minutes, fix NTP on the host rather than widening the window. See clock skew errors for symptoms.
Using iat to Limit Token Age
exp is chosen by the issuer. If your API wants a stricter limit than the issuer grants, use iat to enforce a maximum age on your side. Both Node libraries support this directly:
// jsonwebtoken: throws TokenExpiredError "maxAge exceeded"
jwt.verify(token, key, { algorithms: ['RS256'], maxAge: '1h' });
// jose: ERR_JWT_EXPIRED '"iat" claim timestamp check failed (too far in the past)'
await jwtVerify(token, JWKS, { maxTokenAge: '1h' });With jose, maxTokenAge also makes iat mandatory: a token without it fails with missing required "iat" claim. PyJWT has no max-age option, but it rejects aniat in the future, and you can require claims to be present:
jwt.decode(token, key, algorithms=["RS256"], options={"require": ["exp", "iat"]})
# MissingRequiredClaimError: Token is missing the "exp" claimRequiring exp is worth doing everywhere. Most libraries only check expiry if the claim is present, so a token minted without one, by a misconfigured service or a test script that leaked into production, would otherwise pass as valid indefinitely.
Checking When a JWT Will Expire on the Client
A client sometimes wants to know how long a token has left, for example to refresh a little before expiry instead of waiting for a 401. Decoding the payload is enough for that; no key is needed because the client is not making a security decision:
import { decodeJwt } from 'jose';
const { exp } = decodeJwt(accessToken);
const secondsLeft = exp - Math.floor(Date.now() / 1000);
if (secondsLeft < 60) await refresh();Two caveats. The user's device clock can be wrong by minutes or more, so treat the result as approximate and still handle a 401 by refreshing and retrying once. And if the access token is meant to be opaque to the client, as it is in OAuth, prefer the expires_in value from the token response. The server remains the only place where expiry is enforced; see decoder vs validator for why decoding is not validation.
JWT Expired: What the Error Means
"JWT expired" means the signature may well be valid but the current time is past exp. The fix is to get a new token (refresh or sign in again), not to change the verifier. The exact messages:
- ·jsonwebtoken:
TokenExpiredError: jwt expired, with anexpiredAtdate;NotBeforeError: jwt not activefornbf. - ·jose: code
ERR_JWT_EXPIRED, message"exp" claim timestamp check failed. - ·PyJWT:
ExpiredSignatureError: Signature has expired;ImmatureSignatureError: The token is not yet valid (nbf).
If a token is reported expired seconds after it was issued, check for the '60'-string trap above, a millisecond iat on one side, or a server clock that is ahead. More in the expired token section of the errors guide.
JWT Expiration Time Best Practice
There is no single correct lifetime. The trade-off is simple: a stateless JWT cannot be recalled beforeexp without extra infrastructure, so its lifetime is the window in which a stolen token works. Shorter is safer; longer means fewer refreshes.
- ·Access tokens: minutes, not hours. Somewhere between 5 and 60 minutes is typical; pick the short end for sensitive APIs and pair it with a refresh token so users are not signed out.
- ·Refresh tokens: longer (hours to weeks), but stored server-side or rotated on every use so they can be revoked.
- ·ID tokens: short. They prove a sign-in event to the client and are not meant to be reused as API credentials.
- ·One-off tokens (email links, service-to-service calls, RFC 7523 client assertions): as short as the flow allows, often a few minutes, ideally with a
jtirecorded to block replay.
When choosing within those ranges, ask three questions. How much damage could a stolen token do before it expires? How quickly must a role change or account suspension take effect? And what does a refresh cost your identity provider and your users? A banking API and an internal dashboard give very different answers. Whatever you choose, make the lifetime a configuration value rather than a constant buried in code, so it can be shortened quickly if a leak is suspected, and log iat and exp (not the token) when rejecting requests so expiry problems are easy to diagnose.
exp is not a substitute for sessions. If you need instant logout or permission changes to take effect immediately, you need a revocation mechanism; see JWT logout and revocation.Summary
exp, iat and nbf are NumericDates: integer seconds since the Unix epoch, in UTC. Convert with new Date(exp * 1000),datetime.fromtimestamp(exp, tz=timezone.utc) or date -u -d @exp. In jsonwebtoken, a numeric expiresIn is seconds and a string uses ms syntax; in jose, a number passed to setExpirationTime is absolute. OAuth's expires_in is a duration in seconds. Keep access tokens short, allow tens of seconds of clock skew, and rely on refresh tokens and revocation rather than long lifetimes.