Disclosure: I'm Nagesh, and I built the free JWT Decoder at base64decodetool.com used in this post. The token below is synthetic and illustrative. It uses made-up claims and a made-up demo secret, so nothing in it is real.
Most JWT tutorials stop at "it's three Base64URL parts". The part that bites in production is the time claims. A token that is "expired" on one server and "fine" on another is almost always a timestamp or clock problem.
This post covers three registered claims from RFC 7519:
-
iatis when the token was issued. -
nbfis the time before which it must not be accepted. -
expis the time on or after which it must not be accepted.
All three are NumericDate values: seconds since 1970-01-01 00:00:00 UTC. They are seconds, not milliseconds. JavaScript's Date wants milliseconds, so you multiply by 1000.
An illustrative token
I built this with Python's standard library. Header {"alg":"HS256","typ":"JWT"}, signed with the fake secret demo-secret-not-real:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vLXVzZXItNDIiLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0IiwiaWF0IjoxNzk0NTQ0MjAwLCJuYmYiOjE3OTQ1NDQyNjAsImV4cCI6MTc5NDU0NTEwMH0.bD427BqapYqc8b9zLPkLZ9OyNMMCh8lXbUnkxsjCuxg The payload part decodes to:
{"sub":"demo-user-42","iss":"https://auth.example.test","iat":1794544200,"nbf":1794544260,"exp":1794545100} Unix seconds to human dates
I chose iat as 13 Nov 2026, 10:00:00 IST, then set nbf = iat + 60 and exp = iat + 900 (a 15-minute token). I converted each value with Python and again with Node:
| Claim | Unix seconds | UTC | IST (UTC+5:30) |
|---|---|---|---|
iat | 1794544200 | 2026-11-13 04:30:00 | 2026-11-13 10:00:00 |
nbf | 1794544260 | 2026-11-13 04:31:00 | 2026-11-13 10:01:00 |
exp | 1794545100 | 2026-11-13 04:45:00 | 2026-11-13 10:15:00 |
Both languages agreed. IST is UTC+5:30, so a 04:30 UTC token shows 10:00 in India. A quick sanity check is exp - iat = 900, which is 15 minutes.
In Node:
const [h, p, s] = token.split('.'); const claims = JSON.parse(Buffer.from(p, 'base64url').toString()); new Date(claims.exp * 1000).toISOString(); // 2026-11-13T04:45:00.000Z new Date(claims.exp * 1000) .toLocaleString("en-GB", { timeZone: "Asia/Kolkata" }); // "13/11/2026, 10:15:00" Is it expired? A three-line rule
function status(now, c, leeway = 0) { if (now < c.nbf - leeway) return 'not yet valid'; if (now >= c.exp + leeway) return 'expired'; return 'valid'; } Here now is Unix seconds, for example Math.floor(Date.now()/1000). I tested this against the token with now expressed as seconds after iat:
Seconds after iat | Leeway | Result |
|---|---|---|
| 30 | 0 s | not yet valid |
| 30 | 60 s | valid |
| 930 | 0 s | expired |
| 930 | 60 s | valid |
| 990 | 60 s | expired |
Why leeway (clock skew) exists
Two machines rarely agree to the second. If the issuer's clock runs 20 seconds ahead of the API's clock, a fresh token has an nbf that is still "in the future" for the API, and you get random 401s right after login. A small leeway, commonly tens of seconds to a couple of minutes, absorbs that. Many JWT libraries expose it as an option, such as a clock tolerance. Check your library's docs for the name and its default.
Keep leeway small. It widens the window in which an expired token is accepted. Fix the root cause by syncing clocks with NTP, and use leeway only as a safety margin.
Decoding is not verifying
Anyone can read this payload. Base64URL is an encoding, not encryption, and the first two parts need no key. Reading exp tells you what the token claims. It does not tell you the token is genuine. A forged token can say whatever it likes about exp.
Only after the signature checks out against the right key (and the expected algorithm) should you trust any claim, time claims included. In the HS256 example, I recomputed the HMAC-SHA256 over header.payload with the demo secret in Node and got the same signature as Python, so that check passed. Change one character of the payload and it fails.
How to use the tool
- Open base64decodetool.com/jwt/ and paste the token into Input String.
- Read the decoded header and payload. Below them a status line shows
Expired: …orValid until: …based onexp. - To check the signature, type
demo-secret-not-realinto HMAC Secret Key (UTF-8 text) under Verify Signature. - Use Clear to reset.
The page decodes in your browser and says nothing is uploaded. Still, don't paste live production tokens into any website, mine included. Use test tokens.
What the tool does and doesn't do
Checking against its script, the status line only looks at exp, compares it to your device's clock with no leeway, and prints the time in your browser's local time zone. It doesn't convert iat or nbf to dates and doesn't flag a not-yet-valid nbf. For those, use the conversions above. If you paste my token today, it should say Expired, since its 15-minute window ended at 10:15 IST on 13 Nov 2026.
Checklist
- Treat NumericDate values as seconds, not milliseconds.
- Show times in UTC in logs and convert for humans.
- Check
nbfandexp, not justexp. - Keep leeway small and fix clock drift.
- Verify the signature before trusting any claim.
The token, secret and numbers here are illustrative and created for this post.
