JWT exp/iat/nbf Mistakes: Time-Claim Bugs That Break Auth

JWT time claims — exp, iat, and nbf — are deceptively simple. They're just Unix timestamps. Yet they cause some of the most frustrating authentication bugs in production: tokens that expire too soon, tokens that work on one server but not another, and tokens that never expire at all.
This article covers the most common time-claim bugs, why they happen, and how to fix them with safe defaults.
How exp, iat, and nbf Work
Every JWT can carry three time-related claims in its payload:
- exp (Expiration Time) — the token MUST NOT be accepted after this timestamp
- iat (Issued At) — when the token was created
- nbf (Not Before) — the token MUST NOT be accepted before this timestamp
All three are numeric dates: the number of seconds since 1970-01-01T00:00:00Z (Unix epoch). Not milliseconds — seconds. This distinction alone causes roughly 20% of all JWT time bugs.
Use our JWT Decoder to inspect time claims in any token instantly.
Bug #1: Using Milliseconds Instead of Seconds
JavaScript's Date.now() returns milliseconds. The JWT spec requires seconds. Setting exp to Date.now() + 3600000 creates a token that expires in the year 2089, not in one hour.
// WRONG — milliseconds
const exp = Date.now() + 3600000;
// CORRECT — seconds
const exp = Math.floor(Date.now() / 1000) + 3600;
Most JWT libraries handle this internally, but if you're building payloads manually, this is the first thing to check.
Bug #2: Missing exp Claim Entirely
If you forget to set exp, many libraries will happily create a token that never expires. This is a security risk: a leaked token remains valid forever.
Always set exp. Always validate it on the server. If your library doesn't reject tokens without exp by default, configure it to do so.
// Node.js jsonwebtoken — enforce expiration
jwt.verify(token, secret, { maxAge: '1h' });
Bug #3: Clock Skew Between Servers
Server A issues a token at 14:00:00. Server B's clock says 13:59:55 (5 seconds behind). If the token has nbf: 1711540800 (14:00:00), Server B rejects it as "not yet valid."
This is especially common in:
- Microservice architectures with unsynchronized clocks
- Serverless functions where containers have clock drift
- Mobile apps where the device clock is manually set
The Fix
Allow a small clock tolerance (also called "leeway") — typically 30–60 seconds:
// jsonwebtoken
jwt.verify(token, secret, { clockTolerance: 30 });
// jose (Node.js)
await jwtVerify(token, key, { clockTolerance: '30s' });
Never set tolerance above 2 minutes. If you need more, fix your NTP synchronization instead.
Bug #4: Wrong Validation Order
The correct validation order matters. If you check the signature after checking expiration, an attacker can craft a token with a future exp that passes the time check but has an invalid signature.
Safe validation order:
- Decode header (check algorithm)
- Verify signature
- Check
exp(reject if expired) - Check
nbf(reject if not yet valid) - Check
iat(reject if unreasonably old) - Check issuer, audience, and other claims
Most well-maintained libraries handle this correctly, but custom middleware often gets it wrong.
Bug #5: Timezone Confusion in iat
JWT timestamps are always UTC. But developers sometimes create them using local time:
// WRONG — local timezone
const iat = new Date('2026-03-27T14:00:00').getTime() / 1000;
// CORRECT — explicit UTC
const iat = new Date('2026-03-27T14:00:00Z').getTime() / 1000;
Without the Z suffix, JavaScript interprets the string in the local timezone, which can shift the timestamp by hours.
Bug #6: Accepting Tokens Without nbf Check
The nbf claim is useful for delayed-activation tokens — for example, a token that should only work after a scheduled deployment. If your validator ignores nbf, these tokens can be used before their intended activation time.
Most libraries validate nbf by default, but verify this in your setup, especially with custom middleware.
Bug #7: Overly Long Expiration
Setting exp to 30 days for an access token defeats the purpose of short-lived tokens. Best practices:
- Access tokens: 5–15 minutes
- Refresh tokens: 7–30 days (with rotation)
- ID tokens: 1 hour
For secure refresh token patterns, see our guide on JWT refresh token rotation.
Test Cases Every API Should Have
Add these to your test suite to catch time-claim bugs early:
- Token with
expin the past → 401 - Token with
expexactly now → 401 (boundary) - Token with no
exp→ 401 - Token with
nbfin the future → 401 - Token with
nbfslightly in the future (within tolerance) → 200 - Token with
iatin the future → 401 (indicates tampering) - Token with millisecond timestamps → 401 (detects the ms/s bug)
Safe Defaults for Popular Frameworks
Node.js (jsonwebtoken)
jwt.sign(payload, secret, { expiresIn: '15m' });
jwt.verify(token, secret, {
clockTolerance: 30,
maxAge: '15m'
});
Python (PyJWT)
jwt.decode(token, key, algorithms=['HS256'],
leeway=timedelta(seconds=30),
options={'require': ['exp', 'iat']})
Go (golang-jwt)
parser := jwt.NewParser(
jwt.WithLeeway(30 * time.Second),
jwt.WithValidMethods([]string{"HS256"}),
)
FAQ
Should iat be mandatory?
While the JWT spec says iat is optional, making it mandatory helps with debugging and audit trails. Without iat, you cannot determine when a token was created, making it harder to correlate with security events.
How much clock skew should I allow?
A common safe default is 30–60 seconds. More than 2 minutes introduces security risk. If your systems need more, fix NTP synchronization instead of widening the skew allowance.
What HTTP status code should I return for an expired token?
Return 401 Unauthorized with a clear error body like {"error": "token_expired"}. Do not return 403 Forbidden — that implies the token is valid but lacks permissions, which is a different situation.
Related Tools & Articles
- JWT Decoder — inspect time claims in any token
- JWT Security Mistakes — broader JWT pitfalls beyond time claims
- Refresh Token Rotation — safe pattern for long-lived sessions
- 2FA Guide — add a second layer beyond tokens