How to Check If a JWT Is Expired (the exp Claim Explained)
A JWT is expired when the current time is equal to or later than its exp claim. exp is a plain number of seconds since 1 January 1970 UTC, so 1793372400 means October 30, 2026 at 15:00:00 UTC. To check a token, decode it
A JWT is expired when the current time is equal to or later than its exp claim. exp is a plain number of seconds since 1 January 1970 UTC, so 1793372400 means October 30, 2026 at 15:00:00 UTC. To check a token, decode its middle part, read exp, and compare it with the clock.
That sounds trivial, but most "why is my token rejected?" bugs I've seen come from a handful of traps around that one number. Here's what the spec says, and the pitfalls, tested against PyJWT 2.15.1, jsonwebtoken 9.0.3 and jose 6.2.14.
What RFC 7519 says
There are three time claims, all optional, all NumericDate values (seconds, not milliseconds):
- exp: the token "MUST NOT be accepted for processing" on or after this moment.
- nbf (not before): must not be accepted before this moment.
-
iat (issued at): when it was created. It invalidates nothing by itself, but
exp - iattells you the token's lifetime.
The RFC also allows "some small leeway, usually no more than a few minutes, to account for clock skew". That's why two services can disagree about a token that expired a few seconds ago.
Pitfall 1: exp in milliseconds
Writing Date.now() straight into exp is the classic bug. A token with "exp":1793462400000 was accepted by both jsonwebtoken and jose, so in practice it never expires. PyJWT rejected it, but only because of its iat check, not exp.
Fix: divide by 1000 when you issue the token, and multiply by 1000 when you build a JavaScript Date from exp.
Pitfall 2: exp as a string
"exp":"1798761600" (with quotes) breaks the standard. jsonwebtoken rejects it with invalid exp value, jose with "exp" claim must be a number, and PyJWT accepts and still enforces it. Same token: passes in one service, fails in another.
Pitfall 3: clock skew and leeway
A token that expired 20 seconds earlier was rejected by all three libraries with no tolerance, and accepted by all three with 30 seconds (leeway=30 in PyJWT, clockTolerance: 30 in jsonwebtoken and jose). Keep it small: every second you allow extends the life of every token, including stolen ones.
Pitfall 4: base64 --decode
JWTs use base64url (- and _ instead of + and /, no padding). The popular one-liner cut -d. -f2 | base64 --decode | jq .exp works until a payload contains one of those characters or needs padding, then it fails or prints garbage. Use a base64url-aware decoder.
Pitfall 5: decoding is not verifying
Reading exp tells you when a token claims to expire. It says nothing about whether the signature is valid. Never trust a decoded payload for an authorization decision without verifying it with the key.
No exp at all
A payload like {"sub":"svc-reporting","iat":1790856000} was accepted by all three libraries. A leaked token like that works until the key is rotated. In PyJWT, options={"require": ["exp"]} turns it into MissingRequiredClaimError.
Checking expiry in code
// Node, jsonwebtoken
jwt.verify(token, key, { clockTolerance: 30 }); // throws TokenExpiredError
# Python, PyJWT
jwt.decode(token, key, algorithms=["HS256"], leeway=30,
options={"require": ["exp"]})
Quick manual check without verifying (debugging only):
const p = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
console.log(new Date(p.exp * 1000).toISOString(), Date.now() / 1000 >= p.exp ? 'expired' : 'valid');
If you just want to eyeball a token
I help run a small free tools site, and we built a browser-only JWT decoder that shows the exp/nbf/iat dates in your time zone and in UTC, flags millisecond and string values, and never sends the token anywhere. Even so, treat production tokens like passwords: paste test tokens, or tokens that are already expired.
The full write-up, with worked examples of real decoder output, is on our blog.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.