JWT exp Claim: How to Check If a Token Is Expired in JavaScript and Python
Quick answer
exp is a Unix timestamp in seconds (RFC 7519 calls the type NumericDate). A token is expired when now_seconds >= exp, the RFC says the JWT “MUST NOT be accepted for processing” on or after that time. In JavaScript compare against Math.floor(Date.now() / 1000); in Python against time.time(). Add a leeway of at most a few minutes for clock skew, and remember that reading exp from a decoded payload proves nothing until the signature has been verified.
What RFC 7519 actually says
Section 2 of RFC 7519defines NumericDate as “a JSON numeric value representing the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds.” Three registered claims use it:
| Claim | Meaning (RFC 7519) | Unit | Rejection rule |
|---|---|---|---|
exp | Expiration time (§4.1.4) | seconds | Reject when now >= exp (“on or after”) |
nbf | Not before (§4.1.5) | seconds | Reject when now < nbf |
iat | Issued at (§4.1.6) | seconds | No rule; use it to compute token age |
| leeway | Clock-skew tolerance | seconds | “usually no more than a few minutes”, applies to both exp and nbf |
The most common bug is unit confusion: Date.now() returns milliseconds, so Date.now() > exp is always true for any real token, and a token minted with exp: Date.now() + 3600 expires about one second after issue. Divide once, in one place.
Node.js: decode the payload and compare exp
The payload is the second dot-separated segment, Base64url-encoded without padding. Node's Buffer understands the 'base64url' encoding directly, so no padding repair is needed. This example builds an HS256 token that expired 60 seconds ago, then checks it:
import { createHmac } from 'node:crypto';
// Test token: HS256, exp 60 s in the past
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const now = Math.floor(Date.now() / 1000);
const payload = b64url({ sub: '42', iat: now - 3660, exp: now - 60 });
const sig = createHmac('sha256', 'secret').update(`${header}.${payload}`).digest('base64url');
const token = `${header}.${payload}.${sig}`;
function decodePayload(jwt) {
const part = jwt.split('.')[1];
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
function isExpired(jwt, leewaySeconds = 0) {
const { exp } = decodePayload(jwt);
if (typeof exp !== 'number') return true; // no exp -> treat as unusable
const nowSeconds = Math.floor(Date.now() / 1000);
return nowSeconds >= exp + leewaySeconds; // RFC 7519: "on or after" exp
}
console.log(decodePayload(token));
console.log('expired (no leeway):', isExpired(token));
console.log('expired (120 s leeway):', isExpired(token, 120));
console.log('exp as Date:', new Date(decodePayload(token).exp * 1000).toISOString());
// $ node jwt-exp.mjs
// { sub: '42', iat: 1789642565, exp: 1789646165 }
// expired (no leeway): true
// expired (120 s leeway): false
// exp as Date: 2026-09-17T11:56:05.000ZTwo details worth copying: typeof exp !== 'number' rejects tokens whose exp is a string (a NumericDate must be a JSON number), and the leeway is added to exp, never subtracted from now on the issuer side. In a browser without Buffer, replace - with +, _ with /, re-add = padding and call atob(), see Base64 vs Base64URL.
Python: urlsafe_b64decode needs the padding back
base64.urlsafe_b64decode raises binascii.Error: Incorrect padding on the unpadded segments a JWT contains, so restore the = characters first (-len(s) % 4 gives exactly the number missing):
import base64, json, time
def b64url_decode(s: str) -> bytes:
s += "=" * (-len(s) % 4) # restore the padding JWTs strip
return base64.urlsafe_b64decode(s)
def decode_payload(jwt: str) -> dict:
return json.loads(b64url_decode(jwt.split(".")[1]))
def is_expired(jwt: str, leeway: int = 0) -> bool:
exp = decode_payload(jwt).get("exp")
if not isinstance(exp, (int, float)):
return True
return time.time() >= exp + leeway
print(decode_payload(token))
print("expired (no leeway):", is_expired(token))
print("expired (120 s leeway):", is_expired(token, 120))
# $ python3 jwt_exp.py (same token shape as the Node example)
# {'sub': '42', 'iat': 1789642565, 'exp': 1789646165}
# expired (no leeway): True
# expired (120 s leeway): Falsetime.time() is already in seconds, so no division is needed. Note that isinstance(True, int) is True in Python; if you want to be strict about booleans, add and not isinstance(exp, bool).
Why decoding is not verifying
Both snippets read exp from a payload that anyone can produce: Base64url is an encoding, not a signature. A client can change exp to the year 2100 and the decoded value will still look fine. On the server, the order of operations is fixed: verify the signature with the expected algorithm and key, then evaluate exp, nbf, iss and aud. Mature libraries do this in one call and expose a leeway option; the hand-written checks above are for understanding, client-side UX (“your session will expire in 2 minutes”), and debugging.
An expired token also does not mean the user must log in again: the usual pattern is a short-lived access token plus a refresh token, covered in What Is a JWT?.
Frequently Asked Questions
Is the JWT exp claim in seconds or milliseconds?
Seconds. RFC 7519 defines exp as a NumericDate: the number of seconds since 1970-01-01T00:00:00Z UTC, ignoring leap seconds. JavaScript's Date.now() returns milliseconds, so divide by 1000 (or multiply exp by 1000) before comparing. A token whose exp was written in milliseconds looks like it expires thousands of years in the future.
Is a token expired at exactly the exp second?
Yes. RFC 7519 section 4.1.4 says the JWT "MUST NOT be accepted for processing" on or after the exp time, so the correct comparison is now >= exp, not now > exp. Verifiers may add a small leeway (the RFC says "usually no more than a few minutes") to tolerate clock skew between the issuer and the verifier.
If I can decode the payload and exp is in the future, is the token valid?
No. The payload is only Base64url-encoded, not encrypted, so anyone can decode it and anyone can forge one with a far-future exp. Validity requires verifying the signature with the issuer's key first, then checking exp, nbf, iss and aud. Decoding without verification is fine for display and debugging, never for authorization.
What is the difference between exp, nbf and iat?
All three are NumericDate values in seconds. exp is the time on or after which the token must be rejected. nbf (not before) is the time before which the token must be rejected. iat (issued at) records when the token was created and can be used to compute its age or enforce a maximum age; it is not a rejection rule by itself.
Try it in the browser
Paste a token into the JWT Decoder to see exp, nbf and iat converted to readable dates with the remaining lifetime, or mint a test token with a chosen expiry in the JWT Generator. Both run entirely in your browser.