auth0/node-jsonwebtoken · error

"iat"/"exp"/"nbf" should be a number of seconds

Error message

"iat"/"exp"/"nbf" should be a number of seconds

What it means

The claim validators for iat, exp, and nbf require numbers (seconds since epoch). The schema maps each claim to an isNumber check with a message like '"exp" should be a number of seconds'; passing a string, Date, or non-finite value throws this validator message at sign time.

Source

Thrown at sign.js:56

  exp: { isValid: isNumber, message: '"exp" should be a number of seconds' },
  nbf: { isValid: isNumber, message: '"nbf" should be a number of seconds' }
};

function validate(schema, allowUnknown, object, parameterName) {
  if (!isPlainObject(object)) {
    throw new Error('Expected "' + parameterName + '" to be a plain object.');
  }
  Object.keys(object)
    .forEach(function(key) {
      const validator = schema[key];
      if (!validator) {
        if (!allowUnknown) {
          throw new Error('"' + key + '" is not allowed in "' + parameterName + '"');
        }
        return;
      }
      if (!validator.isValid(object[key])) {
        throw new Error(validator.message);
      }
    });
}

function validateOptions(options) {
  return validate(sign_options_schema, false, options, 'options');
}

function validatePayload(payload) {
  return validate(registered_claims_schema, true, payload, 'payload');
}

const options_to_payload = {
  'audience': 'aud',
  'issuer': 'iss',
  'subject': 'sub',
  'jwtid': 'jti'
};

View on GitHub (pinned to b924272f29)

Solutions

  1. Convert to epoch seconds: Math.floor(new Date(value).getTime() / 1000)
  2. Use Number(value)/parseInt on any string coming from env or query input
  3. Prefer the expiresIn option with a string like '1h' and let the library compute exp
  4. Ensure iat/nbf are also plain numbers if you set them manually

Example fix

// before
jwt.sign({ ...payload, exp: process.env.EXP }, secret);
// after
jwt.sign({ ...payload, exp: Number(process.env.EXP) }, secret);
Defensive patterns

Strategy: type-guard

Validate before calling

function toEpochSeconds(v) {
  if (v instanceof Date) return Math.floor(v.getTime() / 1000);
  const n = Number(v);
  if (!Number.isFinite(n)) throw new TypeError('time claims must be epoch-seconds numbers');
  return n;
}
['exp','nbf','iat'].forEach(c => { if (payload[c] !== undefined) payload[c] = toEpochSeconds(payload[c]); });

Type guard

function isEpochSeconds(v) {
  return typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v);
}

Try / catch

try {
  return jwt.sign(payload, secret, options);
} catch (err) {
  if (/should be a number of seconds/.test(err.message)) {
    const claim = err.message.match(/"(\w+)"/)[1];
    payload = { ...payload, [claim]: Math.floor(new Date(payload[claim]).getTime() / 1000) };
    return jwt.sign(payload, secret, options);
  }
  throw err;
}

Prevention

When it happens

Trigger: jwt.sign({ exp: '2030-01-01' }, secret) or jwt.sign(payload, secret, { expiresIn: 3600 }) is fine, but directly setting exp/nbf/iat to strings, Dates, or numbers-as-strings in the payload fails validation.

Common situations: Reading expiration values from env vars or HTTP input as strings without Number() conversion; passing Date objects instead of Math.floor(date.getTime()/1000); setting exp with a dayjs/moment object.

Related errors


AI-assisted analysis of auth0/node-jsonwebtoken@b924272f29 (2026-09-02). Data as JSON: /api/errors/b8352d9363fd4bc5. Report an issue: GitHub.