Fix the JWT Expired Token Error
A TokenExpiredError, a bare jwt expired message, or NotBeforeError: jwt not active almost always traces back to three numeric claims in the token's payload: iat (issued at), nbf (not before), and exp (expiration). All three are Unix timestamps in seconds, as defined by RFC 7519. The library compares them against the current time and rejects the token when the math says it is outside the valid window. The good news is that the token tells you exactly why if you read it correctly.
Step 1: Decode your token and read the three timestamps
A JWT is three Base64URL-encoded segments separated by dots: header, payload, signature. The middle segment is just JSON, so you can read it without the secret and without ever sending the token to a server. Paste your token into a client-side JWT decoder that runs entirely in your browser, so the token never leaves your machine. Look at the payload for exp, iat, and nbf.
Those values are raw numbers like 1718409600, which are meaningless at a glance. Drop the token into the JWT timeline visualizer to plot iat, nbf, and exp against the current moment and instantly see whether the token is expired, not yet active, or valid. If you only have a single number to convert, paste it into the Unix timestamp converter to turn the epoch value into a human-readable date in your local time and UTC.
Step 2: Is exp actually in the past?
If the converted exp date is genuinely earlier than now, the token has legitimately expired. This is the expected, correct behavior, not a bug. Access tokens are deliberately short-lived. The fix is not to extend lifetimes carelessly but to obtain a fresh token, usually by exchanging a refresh token or re-authenticating.
The common mistake here lives in the refresh logic, not the verifier. If your client only refreshes after a request fails with a 401, every user occasionally sees one broken request. Worse, a buggy refresh endpoint can hand back a token whose exp is already in the past, so it fails on the very first use. Decode the freshly issued token immediately and confirm its exp is comfortably in the future before trusting your refresh flow. Refresh proactively, a short interval before exp, rather than reacting to failures.
Step 3: Rule out the seconds-vs-milliseconds mismatch
This is the single most common self-inflicted JWT bug. The JWT spec requires exp in seconds, but Date.now() in JavaScript returns milliseconds. If you sign a token with exp: Date.now() + 3600000, you have written a timestamp roughly 50,000 years in the future, and many verifiers will reject it outright or behave unpredictably. The inverse error, dividing where you should not, produces an exp in 1970 that is instantly expired.
The tell is obvious once you convert the number. Run the exp through a timestamp converter: a date in the far future or in 1970 means you have a units bug. Let the library compute exp for you instead of hand-rolling it. In Node's jsonwebtoken, pass expiresIn and it handles the seconds math:
const jwt = require('jsonwebtoken');
const token = jwt.sign({ sub: userId }, secret, { expiresIn: '1h' });
// exp is set correctly in seconds; never compute it from Date.now() yourself
Step 4: Add clock-skew leeway
If exp looks correct but the token still fails right around its boundaries, the issuer's clock and the verifier's clock probably disagree by a few seconds. Distributed systems drift, and a token issued on one server can look expired or not-yet-active on another. The standard remedy is a small tolerance window, typically 30 to 60 seconds, applied during verification.
In Node's jsonwebtoken, use the clockTolerance option (in seconds):
jwt.verify(token, secret, { clockTolerance: 30 }, (err, payload) => {
if (err) throw err; // TokenExpiredError or NotBeforeError
// payload is valid within a 30s skew window
});
In Python's PyJWT, the equivalent option is leeway (an integer number of seconds, or a timedelta):
import jwt
payload = jwt.decode(
token, key, algorithms=["HS256"], leeway=30
)
Leeway is a bandage for skew, not a license for sloppiness. Keep it small and run NTP time synchronization on your servers so the clocks barely drift in the first place. A multi-minute leeway window meaningfully widens the period a stolen token stays usable.
Step 5: Catch the timezone and nbf traps
Unix timestamps are inherently UTC, so there is no timezone inside a correct JWT. Timezone bugs creep in when code constructs exp from a local datetime instead of a UTC one. In Python, datetime.now() is naive local time, while datetime.now(timezone.utc) is correct. A token built from local time in a non-UTC zone is offset by your UTC distance, so it expires hours early or late. When you convert the exp and the date is shifted by a whole number of hours from what you expect, suspect a timezone bug at signing time.
If your error is NotBeforeError or "jwt not active" rather than expiry, the failing claim is nbf: the token is valid in the future but not yet. That is usually the same family of bugs (skew or units) applied to the start of the window instead of the end. The same leeway and unit fixes apply. For deeper background on how these claims fit together, see the primer on how JWT tokens work.
A quick decision tree
- Converted
expis genuinely in the past: the token expired as designed. Refresh or re-authenticate, and check that your refresh flow issues a token with a futureexp. expresolves to the far future or to 1970: you have a seconds-vs-milliseconds bug. Stop computingexpby hand and use the library'sexpiresIn.expis correct but fails near the boundary: clock skew. Add 30 to 60 seconds ofclockTolerance(Node) orleeway(Python) and sync server clocks with NTP.expis offset by whole hours: a timezone bug at signing. Build timestamps in UTC, never naive local time.- Error names
nbf/ "not active": same root causes applied to the start of the window. Apply the same leeway and unit fixes.
Work the tree top to bottom and the actual cause usually surfaces within a minute, because every branch is decided by one number you can read straight out of the token.
Frequently Asked Questions
It means the token's exp claim, a Unix timestamp in seconds, is earlier than the current time when the library verified it. The token was valid but its lifetime has passed. Decode the token, convert exp to a readable date, and confirm whether it is genuinely past or whether a units, skew, or timezone bug made it look expired.
Add a small tolerance window during verification so minor clock differences between servers do not reject valid tokens. Use clockTolerance in Node's jsonwebtoken or leeway in Python's PyJWT, both in seconds, typically 30 to 60. Also run NTP on your servers so clocks barely drift, keeping the leeway window small.
Almost always a seconds-versus-milliseconds mismatch. JWT exp must be in seconds, but JavaScript's Date.now() returns milliseconds. Setting exp from Date.now() puts it tens of thousands of years out; dividing wrongly puts it in 1970, so it is instantly expired. Let the library set it via expiresIn instead of computing exp yourself.
Yes, if the tool is genuinely client-side. The payload is only Base64URL-encoded JSON, so reading exp, iat, and nbf needs no secret and no server call. A browser-only decoder never transmits the token. Reading claims does not verify the signature, so never treat decoded data as trusted, but it is safe for diagnosis.
TokenExpiredError fires when the exp claim is in the past, so the token's window has ended. NotBeforeError, or "jwt not active," fires when the nbf claim is in the future, so the token is not valid yet. Both usually share the same underlying causes: clock skew, a seconds-versus-milliseconds units bug, or a timezone mistake at signing time.