← Back to Blog

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

Developer SecurityMar 27, 2026·8 min read
JWT time claims debugging

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:

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:

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:

  1. Decode header (check algorithm)
  2. Verify signature
  3. Check exp (reject if expired)
  4. Check nbf (reject if not yet valid)
  5. Check iat (reject if unreasonably old)
  6. 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:

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:

  1. Token with exp in the past → 401
  2. Token with exp exactly now → 401 (boundary)
  3. Token with no exp → 401
  4. Token with nbf in the future → 401
  5. Token with nbf slightly in the future (within tolerance) → 200
  6. Token with iat in the future → 401 (indicates tampering)
  7. 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