JWT Expired Token Error — Causes and Fixes

Why you see jwt expired errors, how exp works, and how to debug expiry with a browser JWT decoder.

By Mariana Soto · Published 2026-07-02 · Updated 2026-08-22

The jwt expired error (or TokenExpiredError, JWTExpired, depending on your library) means the token's exp claim is in the past relative to the verifier's clock. Compliant libraries reject the token before your route handler or business logic runs. This is one of the most common JWT errors in production — and one of the easiest to diagnose when you know what to look for.

A real-world scenario

Imagine a support ticket: "The mobile app worked this morning, now every API call returns 401." Your API logs show:

TokenExpiredError: jwt expired
  at jwtVerify (...)
  exp: 1735686000, now: 1735689600

The token was valid when issued at 09:00 but expired at 09:15. The user's app cached the access token and kept sending it after exp. No amount of signature verification fixes an expired token — lifetime checks happen first in most libraries.

How exp works

exp is a NumericDate: Unix time in seconds (not milliseconds), defined in RFC 7519 §4.1.4. If the current Unix timestamp is greater than exp, the token must be rejected.

Example from Cyberway's weekly challenge:

{
  "sub": "challenge-user",
  "iss": "cyberway.dev",
  "iat": 1700000000,
  "exp": 1700003600,
  "jti": "cw-challenge-1"
}

Here exp is 1700003600 — November 14, 2023 in UTC. Any verifier running after that instant rejects the token regardless of signature validity.

Clock skew can cause false expirations or brief windows where tokens appear valid when they should not. Production systems often allow a small leeway (30–60 seconds) when comparing exp and nbf, but do not disable expiry checks entirely.

The milliseconds bug

JavaScript's Date.now() returns milliseconds. A frequent bug is storing that value directly in exp:

// Wrong — exp is ~1000× too large or misinterpreted
const payload = { exp: Date.now() + 3600_000 };

// Correct — convert to seconds
const payload = { exp: Math.floor(Date.now() / 1000) + 3600 };

If your library expects seconds but receives milliseconds, behavior varies: some libraries treat the token as far-future valid; others fail parsing. Always confirm units in your issuer and verifier.

Quick check in Node:

const exp = 1700003600;
console.log(new Date(exp * 1000).toISOString()); // UTC human-readable

Debug with Cyberway

When you have a token and need to see expiry at a glance:

  1. Open the Cyberway JWT Decoder.
  2. Paste the token (it stays in your browser — nothing is sent to a server).
  3. Read the Expiry badge: expired, valid for X minutes, or missing exp.
  4. Inspect iat and exp together — a one-hour gap between them usually means a short-lived access token.

For hands-on practice, load the weekly challenge token — it is deliberately expired. Submit exp or expired in the challenge panel to unlock the expiry badge and security callouts.

Common fixes

Issue shorter-lived access tokens. Fifteen-minute access tokens plus refresh tokens limit exposure when a token leaks.

Implement refresh properly. When the API returns 401 with an expired error, exchange a refresh token for a new access token — do not silently retry the same JWT.

Align NTP on verifying servers. If exp checks fail intermittently, compare date output across API nodes. Skewed clocks cause confusing partial outages.

Log exp in human-readable UTC during incident response. Raw Unix seconds are error-prone under pressure.

Avoid multi-year lifetimes that hide missing rotation logic. Long exp values often mean "we never implemented refresh."

Catching expiry in code

With jose:

import { jwtVerify, errors } from "jose";

try {
  await jwtVerify(token, secret, { algorithms: ["HS256"] });
} catch (err) {
  if (err instanceof errors.JWTExpired) {
    // Prompt client to refresh
    return res.status(401).json({ code: "token_expired" });
  }
  throw err;
}

Handle expiry as a expected client flow, not only as an unexpected server error.

Refresh token interaction

Access token expiry is normal. Your client should:

  1. Detect 401 with token_expired (or parse JWTExpired server-side).
  2. Call the refresh endpoint with an HttpOnly refresh cookie or opaque refresh token.
  3. Store the new access token and retry the original request once.

If step 3 loops forever with the same expired JWT, the bug is client-side caching — not API auth configuration.

Monitoring recommendations

Track these metrics separately:

  • jwt_expired_total — client needs refresh; usually benign at low rates.
  • jwt_invalid_signature_total — possible attack or key mismatch; investigate spikes.
  • jwt_missing_total — client integration bug.

Alert on signature failures, not on steady expiry rates during peak logout hours.

Related tools

Paste the same token into the JWT Generator to mint a fresh token with a future exp and confirm your API accepts it — isolating expiry as the only variable.

Preventing repeat incidents

Document the expected access token TTL in your runbook (e.g. 15 minutes). When support sees jwt expired, the first question should be "when was the token issued?" not "did we rotate keys?" Add a staging alert when more than 5% of auth failures in five minutes are JWTExpired — that pattern often precedes a broken refresh deploy rather than an attack.

For the bigger picture, read the Complete JWT Guide and the JWT Challenge Week 1 walkthrough.

Try the Cyberway JWT Decoder or JWT Generator — both run entirely in your browser.