DEV Community

My Botsy
My Botsy

Posted on Originally published at mybotsy.com

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 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 - iat tells 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
Enter fullscreen mode Exit fullscreen mode
# Python, PyJWT
jwt.decode(token, key, algorithms=["HS256"], leeway=30,
           options={"require": ["exp"]})
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)