Skip to content

Is this JWT still valid?

· 7 min read

"The signature checks out, so the token is good" is where a surprising number of auth bugs begin. A signature only proves the token has not been altered since it was issued. It says nothing about whether the token has expired, whether it is meant for your service, or whether the user it refers to still has an account. This guide walks through the claims that decide validity, in the order you should check them.

Three separate questions

When someone asks "is this JWT valid?", they are usually mixing up three checks that fail for completely different reasons:

  • Is it authentic? Does the signature verify against the issuer's key? See verifying a JWT signature.
  • Is it current? Are we inside the window defined by exp and nbf?
  • Is it for us? Do iss and aud match what this service expects?

A token can pass any one of these and fail the others. All three must hold before you act on a single claim inside it.

The time claims

exp — expiry

exp is the moment the token stops being acceptable, as a Unix timestamp in seconds. Not milliseconds. This trips up JavaScript developers constantly, because Date.now() returns milliseconds — compare the two directly and every token looks as though it expired in 1970. Divide by 1000, or use a timestamp converter to read the value while debugging.

nbf — not before

The mirror image: the token is not acceptable until this moment. Issuers sometimes set it a few seconds ahead, or use it to mint a token now that becomes usable later. A token that fails nbf looks bafflingly broken — it is correctly signed, has not expired, and is still rejected.

iat — issued at

When the token was created. Not a validity check on its own, but useful: some services reject tokens older than a maximum age regardless of exp, and it is the field you look at first when debugging a clock problem.

Clock skew, the invisible failure

Machines disagree about the time. If the issuing server's clock runs three seconds ahead of the verifying server's, a freshly issued token has an nbf or iat slightly in the verifier's future, and gets rejected — intermittently, for a few seconds after issue, in a way that is miserable to reproduce.

The standard remedy is a small leeway, typically 30 to 60 seconds, applied to both exp and nbf. Most libraries expose this as a clockTolerance or leeway option. Keep it small: leeway is a window during which an expired token is still accepted, so minutes are too generous.

If you are seeing skew larger than a few seconds between your own servers, the real fix is NTP, not a bigger tolerance.

iss and aud — the checks people skip

iss names the issuer; aud names the intended audience. Both are easy to ignore because tokens work fine without checking them — right up until they do not.

Consider a company with an internal admin API and a public partner API, both trusting the same identity provider. Without an aud check, a token issued for the partner API is a perfectly authentic, perfectly unexpired token that the admin API will also accept. The signature is genuine. The expiry is fine. It was simply never meant for that service. Checking aud is what turns "a valid token" into "a valid token for me".

The hard part: revocation

Here is the structural weakness of JWTs, and it is worth being honest about it. A signed token is valid until it expires, and nothing in the token itself can change that. A user logs out, an admin disables an account, someone rotates a password after a breach — the already-issued token keeps verifying, because verification is a pure computation over bytes that were fixed at issue time. There is no lookup to consult.

The usual answers, each with a real cost:

  • Short expiry plus refresh tokens. Access tokens live five to fifteen minutes; a longer-lived refresh token, which is checked against a database, mints new ones. This is the mainstream approach and it bounds the damage window rather than eliminating it.
  • A denylist of revoked jti identifiers, checked on every request. This works, at the cost of the database lookup JWTs existed to avoid.
  • A per-user token version embedded as a claim and compared against a cached counter, bumped on logout or password change. Cheaper than a full denylist, and revokes every outstanding token for that user at once.

Whichever you choose, notice that a long exp is a security decision, not a convenience one. A 30-day access token means a stolen token is usable for 30 days.

Checking a token safely

To debug any of this you need to look at a real token's claims — and a live JWT is a bearer credential that grants whatever access it was issued for. Pasting a production token into an online decoder sends it to a server you do not control, where it sits in request logs.

KeepItLocally's JWT decoder and verifier decodes the header and payload in your browser, renders exp, iat, and nbf as human-readable times, and badges a token that has already expired or is not yet valid — so the answer to "is this still valid?" is visible without any arithmetic. It can also verify the signature with WebCrypto. Nothing is transmitted: it is a static page with no backend, and its Content-Security-Policy blocks third-party requests outright.

Quick reference

  • exp and nbf are in seconds — divide Date.now() by 1000.
  • Allow 30–60s of clock skew, and fix your NTP if you need more.
  • Always check aud — an authentic token for another service is not valid for yours.
  • JWTs cannot be un-issued — keep exp short and pair with refresh tokens.