JWT Expiration Explained: exp, iat and nbf Claims
How JWT time claims work — exp, iat and nbf — plus clock skew tolerance, choosing a token lifetime, refresh-token rotation and the revocation gap.
ProfitTool Team
Security & Dev
The Three Time Claims
RFC 7519 defines three time-related claims. All three are NumericDate values — seconds since the Unix epoch, in UTC, ignoring leap seconds.
| Claim | Name | Rule |
|---|---|---|
exp | Expiration Time | The token MUST NOT be accepted on or after this time |
nbf | Not Before | The token MUST NOT be accepted before this time |
iat | Issued At | When the token was minted; used to judge its age |
{
"sub": "12345",
"iat": 1769000000,
"nbf": 1769000000,
"exp": 1769003600
}exp − iat is the token's lifetime — here 3600 seconds, one hour.
Two details do most of the damage in practice. First, seconds, not milliseconds: Date.now() in JavaScript returns milliseconds, and passing it straight into exp yields a token that expires around the year 58,000. Use Math.floor(Date.now() / 1000). Second, exp is not optional in any meaningful sense — the specification calls it optional, but a token without one never expires, and a leaked token without one is a permanent credential.
What Expiration Is Actually For
A JWT is verified by checking a signature, not by looking anything up. That is the whole point of the format — and it means the issuer has no way to reach out and cancel a token it has already handed over.
Expiration is the only revocation mechanism a plain JWT has. exp is not a session-length preference; it is the upper bound on how long a stolen token remains useful. Every decision about token lifetime is really a decision about that exposure window.
Clock Skew and Leeway
Two servers rarely agree on the time to the second. If the issuer's clock runs 30 seconds ahead of the verifier's, a token minted with nbf set to "now" is rejected as not-yet-valid for the first 30 seconds of its life.
RFC 7519 anticipates this: implementers may allow a small leeway, "usually no more than a few minutes," when checking exp and nbf.
// Node — jsonwebtoken
jwt.verify(token, key, { algorithms: ["RS256"], clockTolerance: 30 }); // seconds# Python — PyJWT
jwt.decode(token, key, algorithms=["RS256"], leeway=30)Keep leeway small — 30 to 60 seconds. It is a tolerance for imperfect clocks, not a way to extend token life, and every second of leeway is a second of extra validity for an expired token. The real fix is NTP on every host; leeway covers the residual drift.
Choosing a Lifetime
The two forces pull in opposite directions. Short tokens limit the damage from a leak but force frequent refreshes. Long tokens are convenient and dangerous.
| Token type | Typical lifetime | Reasoning |
|---|---|---|
| Access token (browser or mobile app) | 5–15 minutes | Short enough that a leaked token expires before most attacks are mounted |
| Access token (service-to-service) | 5–60 minutes | Same logic; the higher end is tolerable inside a trusted network |
| Refresh token | Days to weeks | Long-lived, but stored more carefully and revocable server-side |
| ID token (OIDC) | 5–60 minutes | Consumed once at sign-in; length rarely matters |
| Email verification / password reset | 15 minutes – 24 hours | Single use, and should be invalidated on use as well as by exp |
The pattern worth internalising: short access token, long refresh token. The access token travels on every request and is exposed constantly; the refresh token is sent only to the token endpoint, stored more defensively, and can be revoked because the authorisation server keeps a record of it.
An hour-long access token is not a compromise between the two — it is a long exposure window with no revocation. If you want longer sessions, lengthen the refresh token, not the access token.
Refresh Tokens and Rotation
The refresh flow:
- Sign-in issues a short access token and a long refresh token.
- The client uses the access token until it expires.
- On a
401, the client posts the refresh token to the token endpoint. - The server validates it against stored state and issues a new access token.
Rotation adds one rule: step 4 also issues a new refresh token and invalidates the old one. That converts a stolen refresh token from an open-ended credential into a single-use one, and — more usefully — makes theft detectable.
If a rotated refresh token is presented twice, one of the two presenters is an attacker, and the server cannot tell which. The correct response is to revoke the entire token family and force re-authentication. This reuse detection is what the IETF's OAuth 2.0 Security Best Current Practice recommends for clients that cannot hold a secret — browser and mobile apps in particular.
Rotation trades statelessness for detectability: the server must now store refresh-token state. That is an acceptable trade, because refresh tokens are presented rarely, so the lookup cost is negligible compared with doing it on every API call.
The Revocation Gap
Between "you revoked access" and "the token expires" there is a window in which a valid signature still opens the door. Demote a user from admin at 10:00:00 and a 15-minute access token minted at 09:59:00 keeps asserting "role": "admin" until 10:14:00.
There is no way to close that gap without server-side state. The options, in increasing cost:
| Approach | How it works | Cost |
|---|---|---|
| Short expiry only | Accept the window; keep it small | None — the window remains |
jti denylist | Store revoked token IDs until their exp passes | One cache lookup per request |
| Token version counter | Embed a version claim; compare against the user record | One lookup per request |
| Introspection endpoint | Verifier asks the issuer about every token | A network call per request — this is a session store |
Choose deliberately. A denylist on every request means you are paying session-store costs while keeping JWT complexity — sometimes correct for a high-value subset of actions, rarely correct across the board. A common middle path is to skip the denylist for ordinary reads and require a fresh token for privileged operations.
Debugging "Token Expired"
| Symptom | Likely cause | Check |
|---|---|---|
| Every token rejected immediately | exp written in milliseconds, or the server clock is wrong | Decode the token; a current exp is ten digits, not thirteen |
| Rejected for the first ~30 seconds, then fine | Clock skew against nbf | Sync clocks with NTP; add small clockTolerance |
| Works locally, fails in CI or containers | Container clock drift, or a fixed clock in a test fixture | Compare date +%s on both hosts |
| Expires far sooner than configured | Library default overriding your value, or expiresIn set in the wrong unit | Decode a freshly issued token and compute exp − iat |
| Intermittent failures under load | Some instances have drifted | Check clock sync across the whole fleet, not one host |
| Valid token rejected after a deploy | Signing key rotated without a kid-based overlap | Keep the previous verification key active until old tokens expire |
The first step is always the same: decode the token and read iat and exp as real dates. Most reports of "the token expires too fast" turn out to be a unit error rather than a configuration one.
Common Mistakes
- No
expat all. Creates a permanent credential out of something designed to be temporary. - Milliseconds in a NumericDate field. Silently produces tokens that never expire.
- Long access tokens "for convenience." The convenience belongs in the refresh token.
- Refresh tokens with no server-side state. A stateless refresh token cannot be revoked, which defeats its purpose entirely.
- Large leeway to paper over clock problems. Fix the clocks.
- Refreshing only on a
401. A request that fails mid-flow after a long idle period is a poor user experience; refresh proactively shortly before expiry. - Assuming timezone matters. NumericDate is always UTC — a timezone mismatch is never the cause. A wrongly set clock is.
Frequently Asked Questions
What happens at the exact moment of expiry? The specification says a token must not be accepted on or after exp. Equality counts as expired.
Can I extend an existing token's expiry? No. Changing any claim invalidates the signature. Issue a new token.
Is a very long exp acceptable if the token is stored securely? Storage reduces the chance of a leak but not the consequence. Lifetime bounds the consequence, and it is the part you control absolutely.
Should refresh tokens be JWTs? Usually not. Refresh tokens need server-side state anyway, so an opaque random string is simpler and reveals nothing.
Do exp and nbf need to be present together? No. exp should effectively always be set; nbf is only useful for tokens intended to become valid later, which is uncommon.
Inspect exp, nbf and iat with live countdowns and human-readable dates using the JWT Claims Inspector, or decode a whole token with the JWT Decoder. To convert a raw NumericDate by hand, use the Unix Timestamp Converter. For the format itself see What Is a JWT?, and for reading a token segment by segment, How to Decode a JWT.
ProfitTool Team
Security & DevWritten and maintained by the ProfitTool team. Where an article references a ProfitTool utility, the behaviour described is the behaviour of the tool on this site — you can open it and check the output yourself.
Try the Interactive Tools Mentioned
Free to use with no ProfitTool signup. Data handling varies by tool.
More from the Knowledge Hub
What Is a JWT? JSON Web Tokens Explained
A plain-language guide to JSON Web Tokens — what they contain, how they are structured, and where they are used in modern authentication systems.
Developer ToolsHow to Decode a JWT Token (Without a Library)
You can decode a JWT token in any language using nothing but base64url decoding and JSON parsing. Here is how to do it in JavaScript, Python and the browser.
Developer ToolsHow to Generate VAPID Keys with web-push, Node or OpenSSL
Generate the VAPID key pair that authenticates your push server to browser push services — using the web-push CLI, Node's crypto module, or OpenSSL.