TokenExpiredError: jwt expired

Node.js · updated 2026-11-11

Thrown by jsonwebtoken's jwt.verify() when the token's exp claim is in the past. The error carries expiredAt. Two siblings come from the same function: JsonWebTokenError "invalid signature" (wrong secret or key) and JsonWebTokenError "jwt malformed" (not a JWT at all, often an empty or "undefined" string).

TokenExpiredError: jwt expired
    at node_modules/jsonwebtoken/verify.js:190:21
    at getSecret (node_modules/jsonwebtoken/verify.js:97:14)
    at module.exports [as verify] (node_modules/jsonwebtoken/verify.js:101:10)
    at requireUser (src/middleware/auth.ts:12:23)
  expiredAt: 2026-11-11T07:39:51.000Z

Quick fix

An expired token is an expected event, not a server bug: catch TokenExpiredError in your auth middleware and answer 401 so the client refreshes or signs in again. If tokens fail right after they are issued, compare clocks between the server that signs and the one that verifies, or allow a few seconds with clockTolerance.

  1. Catch the error in the auth middleware and return 401 with a clear code (for example token_expired) instead of a 500.
  2. On the client, refresh the access token when it gets that 401, then retry the request once.
  3. Compare expiredAt in the error with the current time: hours ago means an old token is being reused; seconds means a short lifetime or clock skew.
  4. Clock skew between machines: allow it with jwt.verify(token, key, { clockTolerance: 5 }).
  5. Don't raise the lifetime to hide the problem; pair short access tokens with a refresh token.
typescript
import jwt from 'jsonwebtoken';

export function requireUser(req, res, next) {
  const token = req.headers.authorization?.replace(/^Bearer /, '');
  try {
    req.user = jwt.verify(token ?? '', process.env.JWT_SECRET!, { clockTolerance: 5 });
    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) return res.status(401).json({ error: 'token_expired' });
    return res.status(401).json({ error: 'invalid_token' });
  }
}
Fix it with your coding agent

Paste this into Claude Code, Cursor or Codex in the project that throws it. The agent reads this guide in plain text and applies the fix that matches your stack.

prompttext
Read https://runheed.com/errors/jwt-expired.md and fix this error in this project.

Why it happens

The client kept an old token

A tab, mobile app or script reuses an access token after its exp without refreshing it.

Very short lifetime

expiresIn set to seconds or minutes, with no refresh flow, so long sessions hit it constantly.

Clock skew

The verifying server's clock runs ahead of the signing server's, so fresh tokens look expired.

Tokens stored somewhere long-lived

A token saved in a cookie, a database or an env var outlives its exp and gets sent again.

Other fixes

  • Count these but don't page on them: a steady rate is normal. A spike after a deploy usually means a changed secret, key or lifetime.
  • "invalid signature" after rotating a secret means old tokens are still in use; accept both keys for one token lifetime.

Same error, other wording

  • JsonWebTokenError: invalid signature
  • JsonWebTokenError: jwt malformed
  • jwt.exceptions.ExpiredSignatureError: Signature has expired

Let your coding agent catch the next one

Runheed records this error in production with the stack trace, the breadcrumbs before it and, when you set it, the agent session that shipped the code, and serves it to Claude Code, Cursor or any MCP client. With it connected, you can ask:

> Find the jwt expired and invalid signature errors, tell me whether they are normal expirations, clock skew or a key change, and make the auth middleware answer 401 with a refresh path instead of 500.

Setup for Node.js and Express or Vercel Functions, then have your agent fix and resolve it.

Related errors

More in Network and HTTP

References: jsonwebtoken README: errors and options (clockTolerance), RFC 7519: the exp claim

Spotted a mistake? Email hello@greatwork.company. About Runheed.