All articles
Engineering

JWT Expiry Debugging: Why Your Token Looks Valid But Your App Logs Users Out

Debugging JWT expiry issues? Learn why valid-looking tokens still fail, how exp, iat, and nbf really work, and how to fix seconds-vs-milliseconds and clock-skew bugs.

Share:

The JWT decodes correctly. The payload has all the expected claims. The expvalue is in the future — at first glance. Yet the app still logs users out, refreshes tokens too early, or rejects API requests that should work.

JWT expiry bugs are frustrating because the token looks valid. The structure is fine. The signature is fine. The claims are present. The problem is almost always in how the consuming code interpretsthose claims — specifically the unit, the clock, or the refresh timing.

TL;DR

  • JWT exp, iat, and nbf are NumericDate values in Unix seconds.
  • JavaScript's Date.now() returns milliseconds by default.
  • Clock skew between issuer and consumer can make a fresh token appear expired or not-yet-valid.
  • Bad refresh timingoften makes a valid token appear “expired” before the app renews it.

Need to inspect a token right now? Use the CodeAva JWT Decoder to inspect the full payload and the Unix Timestamp Converter to verify exp, iat, nbf, and unit mismatches quickly.

The three claims that matter: exp, iat, nbf

exp— expiration time
The token must not be accepted on or after this time. This is the hard deadline. If your current time (in the same unit) is greater than or equal to exp, the token is expired.

nbf— not before
The token must not be accepted before this time. This prevents early use. If the consuming clock is slightly behind the issuer, a fresh token can be rejected because the consumer thinks nbf has not arrived yet.

iat— issued at
Indicates when the token was created. It is not a strict validation boundary by default, but some implementations use it to assess token age or to reject tokens that appear to have been issued in the future (another clock-skew symptom).

These are seconds, not milliseconds

All three claims are NumericDate values as defined in RFC 7519. They represent seconds since the Unix epoch. Not milliseconds. Not formatted date strings. Comparing them to JavaScript's Date.now() without unit conversion is the single most common JWT expiry bug.

The fast comparison table

Claim / ValueWhat it meansCommon unitCommon bug
expExpiration timeSecondsCompared directly to Date.now() milliseconds
nbfEarliest valid timeSecondsToken rejected because server/client clocks differ
iatIssued atSecondsToken rejected as “future-issued” due to clock skew
Date.now()Current JavaScript timeMillisecondsCompared to exp without dividing by 1000

The most common bug: milliseconds vs seconds

This is the bug that causes the majority of “token looks valid but the app logs users out” reports. JavaScript's Date.now() returns milliseconds. JWT exp is in seconds. A direct comparison always evaluates wrong.

// ❌ WRONG: milliseconds vs seconds — condition is ALWAYS true
if (Date.now() > decoded.exp) {
  // Date.now() → 1711699200000 (13 digits, ms)
  // decoded.exp → 1711702800    (10 digits, sec)
  // 1711699200000 > 1711702800 → true, always
  logout();
}

// ✅ CORRECT: convert to seconds first
const nowSec = Math.floor(Date.now() / 1000);
if (nowSec > decoded.exp) {
  logout();
}

// ✅ ALSO CORRECT: convert exp to milliseconds
if (Date.now() > decoded.exp * 1000) {
  logout();
}

The insidious part: this bug often goes unnoticed for weeks because token refresh logic masks it. The frontend tries to use the token, the check says “expired,” refresh fires, a new token arrives, and the user does not notice. The real damage shows up as excessive auth server load, unnecessary token churn, and intermittent failures when the refresh endpoint is slow.

For a deeper look at the seconds-vs-milliseconds problem across languages, read Seconds vs Milliseconds: The Timestamp Bug That Breaks APIs and JWTs. To inspect a specific value, paste it into the Unix Timestamp Converter.

Clock skew: why a valid token can still fail

Clock skew is the time difference between two systems — typically the server that issued the token and the client or service that validates it. Even a few seconds of drift can produce unexpected results:

  • Consumer clock is ahead: the token appears expired before the issuer intended.
  • Consumer clock is behind: nbf rejects a freshly issued token because the consumer thinks the “not before” time has not arrived yet.

Realistic symptoms of clock skew:

  • A token is rejected immediately after issue, even though exp is minutes away.
  • The same token works on one device but fails on another.
  • Refresh loops start earlier than expected on some clients.
  • nbfcauses a brief “not yet valid” rejection window after login.

The fix is usually straightforward: most JWT validation libraries accept a leeway or clockTolerance parameter — typically 5 to 30 seconds. This does not remove the skew; it makes validation tolerant of small differences. For larger skew, the real fix is aligning system clocks via NTP.

Why “looks valid” is misleading

A token can decode perfectly in a tool and still fail in your app. Decoding confirms that the payload is well-formed and readable. It does not confirm that your code handles the values correctly.

Common reasons a decoded-valid token still fails:

  • Unit mismatch: your code compares milliseconds to seconds. The condition is always true, so the token always appears expired.
  • Refresh fires too late: the token expires between the last check and the next API call. The window is small but real under high latency.
  • nbf is present and enforced: the token is not yet valid from the consumer's perspective, even though it was just issued.
  • iat triggers a strict age check: some implementations reject tokens that appear to have been issued in the future (clock skew again).
  • Local time confusion: the developer inspects exp in a local-time display and mentally compares it to UTC-based current time. The numbers do not match, and the developer concludes the token is expired when it is not.
  • Refresh threshold boundary:the token is technically valid but already inside the app's refresh window, so the app treats it as “about to expire” and refreshes prematurely.

Frontend refresh logic: where logout bugs really happen

Most JWT logout bugs are not signature failures or payload corruption. They are timing bugs in the frontend's refresh logic. Here is what to watch for:

  • Do not refresh at the exact exp boundary. Network latency means the refresh request may arrive after the token has already expired. Refresh before expiry.
  • Use a refresh buffer.A common pattern is to refresh when the token has 60–300 seconds of remaining lifetime. The exact value depends on your app's tolerance and typical network conditions.
  • Use consistent units in frontend checks. If your timer compares milliseconds in one place and seconds in another, the refresh timing will be wrong.
  • Do not derive auth state from display formatting. If you format exp into a local-time string for the UI, do not use that string for expiry logic. Keep logic based on raw numeric comparison.
  • Clean up timers. If a refresh timer is not cleared on logout or token replacement, stale timers can trigger unexpected refresh attempts.
  • Distinguish token types. Access tokens, refresh tokens, and session cookies have different lifetimes and revocation rules. Treating them interchangeably causes subtle failures:
    • Access token expiry— short-lived, handled by refresh logic.
    • Refresh token expiry— longer-lived, requires re-authentication when exhausted.
    • Session inactivity / backend revocation— not tied to token claims at all; requires a server round-trip to detect.

How to debug JWT expiry safely

Use this checklist any time a token appears valid but your app rejects it:

Debugging stepStatus
Decode the token and read raw exp, iat, and nbf values
Confirm whether values are seconds (10 digits) or milliseconds (13 digits)
Compare against current UTC time in the same unit
Check whether the client clock differs from the issuing server
Verify that refresh fires before exp, not after
Check whether nbf blocks immediate use
Confirm the app is not treating local display time as logic time
Verify whether backend validation uses leeway or strict boundaries

Use the CodeAva JWT Decoder to inspect the full payload and the Unix Timestamp Converter to confirm claim values and units.

Safe implementation patterns

Expiry check (JavaScript)

// Both sides in seconds — safe comparison
const nowSec = Math.floor(Date.now() / 1000);

function isTokenExpired(decoded: { exp: number }): boolean {
  return nowSec >= decoded.exp;
}

Refresh threshold (JavaScript)

const REFRESH_BUFFER_SEC = 120; // refresh 2 minutes before expiry

function shouldRefresh(decoded: { exp: number }): boolean {
  const nowSec = Math.floor(Date.now() / 1000);
  return decoded.exp - nowSec <= REFRESH_BUFFER_SEC;
}

Debugging output

// Log both raw values and human-readable UTC during debugging
const nowSec = Math.floor(Date.now() / 1000);
console.log({
  expSec: decoded.exp,
  expUTC: new Date(decoded.exp * 1000).toISOString(),
  nowSec,
  nowUTC: new Date().toISOString(),
  remainingSec: decoded.exp - nowSec,
});

Keep units explicit in variable names: expSec, nowSec, expiresAtMs. This prevents the bug at the naming level.

API and auth architecture advice

  • Make units explicit in payloads and internal code. If a field is seconds, name it accordingly. If your middleware converts to milliseconds internally, document it.
  • Document token lifetime assumptions. State how long access tokens and refresh tokens live, and whether leeway is applied during validation.
  • Avoid vague names like expiry or timestamp. Without a unit suffix, consumers will guess — and guess wrong across service boundaries.
  • Do not rely on frontend clocks for critical trust decisions. Frontend expiry checks improve UX but should not be the sole enforcement point. The backend is the authority.
  • Use consistent validation rules across services. If one microservice applies a 30-second leeway and another uses strict boundaries, the same token can be accepted by one and rejected by the other.

For broader cross-language reference on timestamp handling, read The Developer's Cheat Sheet: Unix Timestamps in JS, Python, Go, PHP, and SQL.

Conclusion

If a JWT expiry bug feels confusing, check three things in order:

  1. Units— are you comparing seconds to seconds, or accidentally comparing milliseconds to seconds?
  2. Clock skew— is the consumer's clock aligned with the issuer within a reasonable tolerance?
  3. Refresh timing— does the app refresh before expiry, with enough buffer for network latency?

Most “mystery” logout bugs come from one of those three causes. Decoding the token is the start of debugging, not the end.

Use the CodeAva JWT Decoder to inspect exp, iat, and nbf. Use the Unix Timestamp Converter to confirm seconds vs milliseconds. For the most common timestamp bug in detail, read Seconds vs Milliseconds: The Timestamp Bug That Breaks APIs and JWTs.

Want broader code quality checks across your auth logic? Run a Code Audit to catch structural issues before they reach production.

#jwt#auth-debugging#token-expiry#clock-skew#javascript#unix-timestamp#frontend-auth

Frequently asked questions

More from Jerome James

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.