Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

JWT Time Claims Explained: exp, iat and nbf in Plain Dates (Plus Clock Skew)

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 rea

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:

  • iat is when the token was issued.
  • nbf is the time before which it must not be accepted.
  • exp is 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

  1. Open base64decodetool.com/jwt/ and paste the token into Input String.
  2. Read the decoded header and payload. Below them a status line shows Expired: … or Valid until: … based on exp.
  3. To check the signature, type demo-secret-not-real into HMAC Secret Key (UTF-8 text) under Verify Signature.
  4. 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 nbf and exp, not just exp.
  • 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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.