What the verifier saw
Standard libraries report this differently. Common forms:
JsonWebTokenError: jwt expired
ExpiredSignatureError: Signature has expired
TokenExpiredError: TokenExpiredError: jwt expired
401 Unauthorized — token expired Underneath, all of them ran the same check:
now >= payload.exp + leeway
Three knobs: now (the verifier's clock),
payload.exp (set by the issuer), and
leeway (a tolerance for skew, often 0 by default).
The five causes, ranked by frequency
1. The token genuinely expired
Most common, least surprising. Your access token's TTL elapsed between issuance and use. Refresh it.
// Pseudo
if (response.status === 401) {
const fresh = await refresh(refreshToken);
return retry(request, fresh.accessToken);
} Build this once, at the HTTP-client layer, not per-call. If you find yourself catching expired-token errors in business logic, the abstraction is wrong.
2. Clock skew between issuer and verifier
If the verifier's clock is 30 seconds ahead, a token issued with
a 30-second lifetime is dead on arrival. If the verifier's clock
is behind, you'll see "not yet valid" (nbf) errors
instead.
Fix: enable leeway in the verifier (most libraries support it), and run NTP / chrony on every host.
// node — jsonwebtoken
jwt.verify(token, secret, { clockTolerance: 30 });
// python — pyjwt
jwt.decode(token, secret, leeway=30, algorithms=["HS256"])
// go — jwt-go
parser := jwt.NewParser(jwt.WithLeeway(30 * time.Second)) 3. Wrong timezone in the issuer
exp is Unix seconds — UTC, no timezone. If your
issuer computes it from local time and converts wrong, every
token is shifted by your offset (e.g., -7 hours in PDT).
Symptom: tokens fail consistently by exactly your timezone offset.
Fix: always use time.time(), Date.now() / 1000,
time.Now().Unix() — never the local-time variant.
4. exp is in milliseconds, not seconds
JWT spec is unambiguous: seconds. JavaScript's Date.now()
returns milliseconds. Forgetting to divide by 1000 produces a token
that "expires in 1971" (1.7 trillion seconds past epoch). Verifiers
treat that as expired by ~50 years.
// Wrong
const exp = Date.now() + 3600 * 1000;
// Right
const exp = Math.floor(Date.now() / 1000) + 3600; 5. Cached token in the client
A service worker, localStorage, or in-memory copy holds an old token after a logout/refresh cycle. The user re-authenticated; the cache didn't update. Hard-refresh, clear storage, or invalidate the cache key on logout.
Detection: paste your token
Our JWT decoder reads the
exp claim and shows "expired N min ago" or "expires
in N min" inline. Runs in your browser; the token never leaves
the page.
Defensive defaults
- Access token TTL: 5–15 minutes. Refresh token TTL: 1–30 days, rotated on use.
- Verifier leeway: 30–60 seconds. Smaller and skew bites; larger and revoked tokens stay valid too long.
- One refresh path, at the HTTP layer. No retry storms — if refresh fails, surface to the user.
- NTP on every host. JWT problems are almost always clock problems in disguise.
Related
FAQ
How do I check if my JWT is expired without verifying?
Decode the payload (Base64 URL of the second segment), parse as JSON, and compare the 'exp' field (Unix seconds) to the current time. Our /decode-jwt page does this in your browser and surfaces the time-to-expiration directly.
What's the difference between exp, nbf, and iat?
All three are Unix timestamps in seconds. 'exp' is when the token stops being valid. 'nbf' (not before) is when it starts being valid; verifiers reject tokens used earlier. 'iat' (issued at) is when it was created — informational, used for auditing, not directly checked.
Why does my token expire 'in the future' according to the verifier?
Server clock skew. Two machines whose clocks disagree by 60 seconds will see a fresh token as either pre-issued or expired depending on direction. Configure leeway (typically 30–60s) in your verifier and run NTP on every host.
Should I just extend exp to a year and forget about it?
No. Long-lived access tokens are the dominant cause of stolen-token incidents. The pattern is short-lived access (5–15 min) plus a longer-lived refresh token rotated on use. The refresh token sits in an httpOnly cookie or secure store and never reaches client JS.
Can I refresh a token after it's expired?
Not directly — the access token is dead. You exchange the refresh token (which has its own, longer expiration) for a new access token. If the refresh token is also expired or revoked, the user must re-authenticate.