You decode a JWT on the client to grab the expiry, write what looks like an obvious check, and suddenly every user is either logged out instantly or never logged out at all. The token is fine. Your comparison is off by a factor of 1000. Here's why, and how to get it right.
Peeking inside a JWT
A JWT is three Base64URL segments joined by dots: header.payload.signature. The middle segment is the claims. To read them in the browser you split on the dots and decode the payload:
function decodePayload(token) {
const payload = token.split(".")[1];
// Base64URL → Base64, then decode + parse
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
}
decodePayload(token);
// { sub: "1234", name: "Ada", iat: 1710000000, exp: 1710003600 }
So far so good. Then you try to check whether it's expired.
The bug
const { exp } = decodePayload(token);
if (Date.now() > exp) {
logout(); // 💥 fires immediately, for every token
}
Date.now() returns milliseconds since the Unix epoch — right now that's a 13-digit number like 1710000000000. But exp in a JWT is a NumericDate: seconds since the epoch (RFC 7519), a 10-digit number like 1710003600.
So you're comparing 1710000000000 > 1710003600, which is always true. Every token reads as expired, and your users get bounced the moment they log in.
Flip the comparison the wrong way to "fix" it and you get the opposite failure: exp > Date.now() is always true, so tokens never expire on the client and stale sessions linger.
The fix: put both sides in the same unit
Multiply the JWT's seconds up to milliseconds, or divide Date.now() down to seconds. Pick one and be consistent:
const { exp } = decodePayload(token);
// Option A — compare in milliseconds
const isExpired = Date.now() >= exp * 1000;
// Option B — compare in seconds
const nowSec = Math.floor(Date.now() / 1000);
const isExpiredB = nowSec >= exp;
The same trap applies to iat (issued-at) and nbf (not-before) — both are NumericDate seconds. If you want a real Date object for display, remember the multiply:
new Date(exp * 1000).toISOString(); // "2024-03-09T17:00:00.000Z"
new Date(exp); // 🐞 1970 — forgot the *1000
A small clock-skew allowance is also worth adding so a token that expires in the next second or two isn't treated as still-valid:
const SKEW_SEC = 30;
const isExpired = Math.floor(Date.now() / 1000) >= exp - SKEW_SEC;
The bigger gotcha: decoding is not verifying
This part matters more than the timestamp. atob + JSON.parse only reads the payload — it does nothing to check that the token is genuine. Anyone can craft a JWT with exp set to next year and admin: true, because the signature (the third segment) is what proves authenticity, and you didn't check it.
That's fine for what client-side decoding is for: reading a username to show in the navbar, or checking exp to decide when to silently refresh. It is never okay as an authorization decision. The server must verify the signature against its secret/public key on every protected request — and reject alg: none tokens outright.
// OK on the client: a UI hint
navbar.textContent = decodePayload(token).name;
// NOT OK on the client: an auth gate
if (decodePayload(token).role === "admin") showAdminPanel(); // trivially forged
Three things to remember
- JWT
exp,iat, andnbfare seconds (NumericDate);Date.now()is milliseconds. Multiply by 1000 or divide by 1000, but never mix them. - Add a few seconds of clock-skew tolerance so borderline-expired tokens are handled sanely.
- Decoding a JWT reads it; it does not trust it. Signature verification belongs on the server.
I kept re-eyeballing 13-digit timestamps to sanity-check this, so I keep a small free JWT decoder around that lays out the header, claims, and a human-readable exp for me.
Top comments (0)