Skip to content
DevPenh

JWT Expired? Read exp, iat and nbf Correctly

What exp, iat and nbf mean, why a JWT can expire early, and how to handle TokenExpiredError without ignoring the signature.

By Yorn Chanpenh · Last reviewed

A JWT is expired when its exp claim is earlier than the verifier's clock. The error usually appears as TokenExpiredError: jwt expired, not as a message about the signature. This guide shows how to read the timestamp claims, check them correctly, and handle expiry without disabling verification.

The examples use jsonwebtoken 9.0.2 on Node.js 22.23.4. The same claim rules apply to other libraries; only the error class names change.

Decode first, but do not trust the result

The payload is Base64URL, not encrypted. You can inspect it without the secret:

const jwt = require("jsonwebtoken");

const payload = jwt.decode(token);
console.log(payload);

For a token signed a few minutes ago, the payload looks like this:

{
  sub: "user_123",
  role: "admin",
  iat: 1793797858,
  exp: 1793798158
}

decode only reads the payload. It does not check the signature, issuer, audience or expiry. Use it for debugging and display; never use it as an authorization check. The JWT decoder does the same local-only inspection in your browser.

What exp, iat and nbf mean

ClaimMeaningTypical error
expExpiration time: the token must not be accepted after it.TokenExpiredError: jwt expired
iatIssued-at time. Useful for token age; not an expiry check.None by itself
nbfNot-before time: the token is not valid before it.NotBeforeError: jwt not active

All three are seconds since 1970-01-01 UTC, not milliseconds. That is the same unit as a POSIX timestamp. If a value is 1,793,797,858, the date is in 2026; if it is 1,793,797,858,000, someone used milliseconds. See Unix timestamps explained for the conversion.

Check expiry on the server

Let the library verify the signature and the time claims together. Catch the expiry error separately so you can return a useful response:

const jwt = require("jsonwebtoken");

try {
  const payload = jwt.verify(token, process.env.JWT_SECRET);
  console.log("valid until", new Date(payload.exp * 1000).toISOString());
} catch (error) {
  if (error.name === "TokenExpiredError") {
    console.error("expired at", error.expiredAt.toISOString());
    // Return 401 and let the client refresh the token.
  } else {
    throw error;
  }
}

error.expiredAt is a Date the library derives from exp. Do not compare exp to Date.now() directly: Date.now() is in milliseconds and exp is in seconds.

Compute the remaining lifetime correctly

const payload = jwt.decode(token);
const now = Math.floor(Date.now() / 1000);
const secondsLeft = payload.exp - now;

console.log(secondsLeft); // 300 for a five-minute token just issued

This is useful for a client countdown, not for authorization. A client can change its clock; the server must still call verify.

nbf and clock skew

nbf is checked the same way as exp, but in the opposite direction. A token signed with a not-before time one hour ahead fails immediately:

const jwt = require("jsonwebtoken");
const token = jwt.sign({ sub: "user_123" }, secret, { notBefore: "1h" });

try {
  jwt.verify(token, secret);
} catch (error) {
  console.log(error.name); // "NotBeforeError"
  console.log(error.date.toISOString()); // the nbf time
}

A small clock difference between servers is normal. Most libraries let you allow a few seconds of tolerance:

jwt.verify(token, secret, { clockTolerance: 30 }); // 30 seconds

Keep this small. A large tolerance means an expired token remains valid for longer. If two servers disagree by minutes, fix their clocks with NTP rather than hiding the problem in application code.

Why a token expires earlier than expected

  • The token lifetime is short. Check exp - iat; a 15-minute or 5-minute access token is normal.
  • Milliseconds were used as seconds. An exp of 1,793,797,858,000 is far in the future when read as seconds, not 2026.
  • The verifier clock is wrong. Compare Date.now() / 1000 with the token's exp on the server that rejects it.
  • The token came from another environment. Staging and production tokens often have different signing keys and lifetimes.
  • exp is missing. A token without exp does not expire automatically unless the verifier requires the claim. Set an explicit lifetime.

Do not fix expiry by ignoring it

jwt.verify(token, secret, { ignoreExpiration: true }) is not a refresh mechanism. It accepts a stolen token forever. The usual flow is:

  1. The server returns 401 for an expired access token.
  2. The client sends its refresh token to a dedicated refresh endpoint.
  3. The server verifies the refresh token and issues a new short-lived access token.
  4. If the refresh token is expired or revoked, sign the user in again.

Decoding alone never proves that a token is valid. For the difference between reading claims and verifying a signature, see JWT decoding vs signature verification.

Inspect the token without sending it

The JWT decoder shows exp, iat and nbf in UTC and local time, and warns when the token is expired or not yet valid. Convert a claim with the timestamp converter when you need to compare it with a log entry.

Part of the API debugging series. Start with How to Debug API Responses: A Practical Workflow.