The Three Time Claims in a JWT
RFC 7519 defines three time-related claims. All three are Unix timestamps — the number of seconds since 1970-01-01T00:00:00Z. They can appear together or independently in the same payload.
iat — Issued At
The exact Unix time when the auth server minted the token. Useful for age-based decisions ("this token is older than 5 minutes, require re-authentication for this sensitive action") and for correlating a token to a login event in your logs.
nbf — Not Before
The Unix time before which the token must be considered invalid. Verifiers must reject a token whose nbf is in the future. Usually equal to iat, but can be set later for tokens issued in advance of a scheduled activation.
exp — Expiration
The Unix time after which the token must be considered invalid. Verifiers must reject a token whose exp is in the past. The single most-checked field in JWT verification.
Converting exp to a Readable Date
JavaScript / Node.js
// The critical multiplication is by 1000 — JS Date wants milliseconds
new Date(exp * 1000).toISOString()
// -> "2026-01-01T00:00:00.000Z"
// Local timezone:
new Date(exp * 1000).toLocaleString()
// -> "1/1/2026, 5:30:00 AM" (in IST)
// Is it expired?
const expired = exp < Math.floor(Date.now() / 1000);Python
from datetime import datetime, timezone
datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
# -> "2026-01-01T00:00:00+00:00"
# Local timezone:
datetime.fromtimestamp(exp).isoformat()
# -> "2026-01-01T05:30:00" (in IST)
# Is it expired?
import time
expired = exp < time.time()Bash / Command Line
# macOS
date -r 1735689600
# -> Wed Jan 1 05:30:00 IST 2026
# Linux (GNU date)
date -d @1735689600
# -> Wed Jan 1 00:00:00 UTC 2026
# Compare to now
[ 1735689600 -lt "$(date +%s)" ] && echo "EXPIRED" || echo "VALID"The Milliseconds-vs-Seconds Bug
Half of all "my JWT expiry is broken" questions come from this one bug: JWT claims use Unix seconds, but Date.now() in JavaScript returns Unix milliseconds. If you pass Date.now() directly into a JWT payload, the resulting exp value is 1000× too large — a token issued today will appear to expire in the year 55000.
// WRONG — exp will be around 1.75 trillion, expiring in year 55000+
{ exp: Date.now() + (15 * 60 * 1000) }
// CORRECT — exp in seconds
{ exp: Math.floor(Date.now() / 1000) + (15 * 60) }Symptom: your verifier accepts the token indefinitely, and your tool converts exp to a nonsensical date. Fix: divide by 1000 when writing exp; multiply by 1000 when reading it back into a JavaScript Date object.
Clock Skew and Leeway
Even with NTP running everywhere, servers drift by a few seconds. A token issued at t=1000 with a 15-minute lifetime expires at t=1900. If the verifier's clock is 5 seconds ahead of the issuer's, the token will be checked against t=1905 at the exact same instant — a 5-second false-expiry window at the boundary.
Every serious JWT library supports a leeway (also called "clock tolerance") option that adds a small buffer:
- jsonwebtoken (Node.js):
clockTolerance: 60 - PyJWT (Python):
leeway=60 - jose (Node.js/browser):
clockTolerance: '60s' - golang-jwt:
jwt.WithLeeway(60 * time.Second)
Set leeway to 30-60 seconds. Do not set it to 0 (breaks legitimate users on unsynced devices). Do not set it above 5 minutes (weakens the security guarantee of exp).
Refresh Token Pattern — Because You Cannot Extend exp
The signature protects exp, so you cannot "extend" a token. The industry-standard solution is the refresh token pattern:
- User logs in. Server issues access token (exp = 15 min) and refresh token (exp = 30 days).
- Client uses access token for API calls. After 15 min, API returns 401 with error
TokenExpiredError. - Client POSTs refresh token to
/auth/refresh. Server verifies it against a database (not just signature — refresh tokens should be revocable) and issues a fresh access token. - Client retries the original request. Everything continues transparently.
For extra security, rotate the refresh token: return a new refresh token in the refresh response and invalidate the old one. If a stolen refresh token is used, the legitimate user's next refresh will fail — a strong signal to force re-authentication.
Choosing an Appropriate Expiration
- Access token (default):
- 15 minutes
- Access token (high-security):
- 5 minutes
- Access token (max reasonable):
- 1 hour
- Refresh token (mobile app):
- 30 days
- Refresh token (web app):
- 7-14 days
- Refresh token (banking):
- hours or shorter
- Never do:
- Access tokens with no exp — a stolen token lives forever
Debugging "My Token Says It Is Not Expired But My API Rejects It"
- Check nbf — if it is in the future, the token is not yet valid. Clock skew or intentional delay.
- Check leeway — your verifier may have 0 leeway and be catching legitimate clock drift.
- Check exp units — someone might have set exp in milliseconds by accident; both your tool and your verifier convert this to a huge future date, but a different service in the chain may reject as malformed.
- Check the token you are actually sending — a client bug may cause you to send an old cached token while a newer one sits unused in storage.
- Check for signature failure masquerading as expiry — some servers respond with a generic 401 for both cases. Read the actual error code (TokenExpiredError vs JsonWebTokenError).
Key Facts
- exp format:
- Unix timestamp in seconds (not milliseconds)
- Comparison:
- expired if exp < Math.floor(Date.now() / 1000)
- Related claims:
- iat (issued at), nbf (not before)
- Recommended lifetime:
- 5-15 min for access tokens; hours-to-days for refresh
- Cannot be extended:
- Signature is bound to exp — issue a new token instead
- Clock skew leeway:
- Set 30-60 seconds in your verifier
Related JWT Tools
- JWT Decoder Online — decode any token to see all its claims
- Verify JWT Signature — HS256 & RS256 verification walkthrough
- JWT Decoder Node.js — handling TokenExpiredError in Express
- JWT Decoder Python — PyJWT expiration handling
- JWT Generator Online — create test tokens with custom exp values