How to Check if a JWT Is Expired: exp, iat, nbf, Clock Skew and Refreshing Tokens
A JWT carries its own expiry date in the `exp` claim. Checking it looks trivial, yet expiry causes a large share of authentication bugs: tokens that expire "too early" because of a skewed clock, sessions that never expire because someone stored milliseconds instead of seconds, and users thrown out mid-form because nothing refreshed the token. Reading the date is one line of code; the rest is getting the checks and the refresh logic around it right.
exp, iat and nbf: three dates in seconds
JWT time claims use RFC 7519's NumericDate type: seconds since 1 January 1970 UTC, not milliseconds.
{
"sub": "user_42",
"iat": 1790000000, // issued: 2026-09-21 14:13:20 UTC
"nbf": 1790000000, // not valid before that moment
"exp": 1790000900 // expires: 15 minutes later
}
exp is the moment from which the token must no longer be accepted. nbf (not before) is the moment before which it must not be accepted either. iat (issued at) only records when it was issued; it invalidates nothing by itself, although some systems use it to reject tokens that are too old. All three are optional in the specification, but an API should require exp.
Checking expiry in JavaScript, Python and the shell
// Read exp WITHOUT verifying: only for display or for scheduling a refresh
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
const expiresAt = new Date(payload.exp * 1000); // seconds -> milliseconds
const expired = Date.now() >= payload.exp * 1000;
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
import time, jwt # PyJWT
claims = jwt.decode(token, options={'verify_signature': False})
expired = time.time() >= claims['exp']
# Show when a token expires
exp=$(printf '%s' "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .exp)
date -u -d "@$exp" # Linux (macOS: date -u -r "$exp")
Mixing units is the most frequent bug. Date.now() in JavaScript returns milliseconds. Compared directly with exp, every token looks expired decades ago; stored as exp when creating a token, it makes the token expire more than 50,000 years from now. Multiply or divide by 1000 in exactly one place.
Decoding is not validating
Reading exp from the payload is fine for display, or for deciding on the client when to refresh. It is not a basis for accepting a request, because anyone can forge a token with an exp in the year 2100. On the server, expiry is checked as part of full verification: signature, allowed algorithm, iss, aud and the dates.
// Node.js with jose: verification includes exp and nbf
import { jwtVerify, errors } from 'jose';
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['RS256'], issuer: 'https://auth.example.com/', audience: 'https://api.example.com',
clockTolerance: 30,
});
} catch (e) {
if (e instanceof errors.JWTExpired) { /* 401: the client should refresh */ }
throw e;
}
Each library reports expiry with its own error: JWTExpired (code ERR_JWT_EXPIRED) in jose, TokenExpiredError in jsonwebtoken, and jwt.ExpiredSignatureError in PyJWT. Handle it separately from an invalid signature. An expired token is normal and the client can refresh it; an invalid signature may mean tampering.
Clock skew
The issuer and the verifier compare dates using their own clocks. If they differ by a few seconds, a freshly issued token can look "not yet valid" (nbf in the future) or expire earlier than expected. Libraries therefore allow a tolerance: clockTolerance in jose and jsonwebtoken, leeway in PyJWT.
Keep the tolerance small, 30 to 60 seconds, and keep servers synchronised with NTP. A tolerance of several minutes silently extends the life of every token, which is exactly what short expiry is meant to prevent.
If a token works in one environment and fails in another with the same exp, compare the two servers' actual time before reviewing code. Containers and virtual machines can drift after being suspended.
Refresh before it expires
Access tokens should be short-lived, 5 to 15 minutes, to limit the damage if one leaks. So that users do not have to sign in every quarter of an hour, they are paired with a long-lived refresh token that obtains new access tokens.
There are two client strategies, and it is best to use both. The proactive one schedules a refresh shortly before exp, for example a minute ahead. The reactive one, in an HTTP interceptor, catches a 401, refreshes once and replays the original request.
let refreshing = null; // prevents several parallel refreshes
async function fetchWithAuth(url, options = {}) {
let res = await fetch(url, { ...options, headers: { Authorization: `Bearer ${currentToken}` } });
if (res.status === 401) {
refreshing ??= refreshToken().finally(() => { refreshing = null; });
await refreshing;
res = await fetch(url, { ...options, headers: { Authorization: `Bearer ${currentToken}` } });
}
return res;
}
Enable refresh-token rotation in your identity provider: each use returns a new refresh token and invalidates the old one, so a stolen refresh token stops working as soon as the legitimate user uses theirs. Remember that a JWT's expiry cannot be brought forward. To revoke a token before exp, you need a revocation list, for example of jti values, or tokens short enough that waiting is acceptable.
Sources and further reading
RFC 7519 defines JWT and the exp, nbf and iat claims as NumericDate. RFC 8725 collects JWT security best practices. RFC 6749 and the OAuth 2.0 Security Best Current Practice describe refresh tokens and rotation. The jose, jsonwebtoken and PyJWT documentation describe their expiry errors and tolerance options.
Frequently asked questions
How do I check if a JWT is expired?
Decode the payload and compare `exp`, in seconds, with the current time: in JavaScript, `Date.now() >= payload.exp * 1000` means expired. On the server, let the library check it while verifying the token.
Is the exp claim in seconds or milliseconds?
Seconds since 1970 (NumericDate). Multiply by 1000 only when converting it to a JavaScript date.
Why does my token expire too early?
Usually clock skew between the issuer and the verifier, or a wrong seconds-versus-milliseconds conversion. Sync clocks with NTP and allow a 30–60 second tolerance.
What should I do on TokenExpiredError?
Respond with 401 and let the client refresh the token with its refresh token and replay the request. Do not treat it as an invalid signature: expiry is a normal event.
How long should a JWT access token last?
5 to 15 minutes is typical for access tokens, combined with rotating refresh tokens. A leaked token is then useful only briefly, without bothering the user.
Can a JWT be invalidated before it expires?
Not directly: a signed JWT is valid until `exp`. Revoking it earlier needs a revocation list, for example of `jti` values, or checking with the provider through token introspection.