Stateless Tools

Three time claims

JWT has three time-related claims and all three are Unix epoch seconds, not milliseconds. That single line explains half of the bugs in practice.

exp (expiration)

Must not be accepted after this instant. If the current time equals exp exactly, the token is still valid. It is not a required claim, so its absence yields a token that never expires — usually by mistake.

nbf (not before)

Must not be accepted before this instant. Used to issue a token that becomes valid later. Rarely needed, but if the issuing server's clock runs slightly fast, a freshly minted token gets rejected as "not yet valid".

iat (issued at)

When the token was issued. Not used for validity directly, but for your own policies such as "is this token too old" and for audit logs. Using iat for expiry alongside exp creates two competing rules.

Paste a token into the JWT decoder to inspect the values, and use the Unix timestamp converter to turn epoch numbers into readable times.

Decoding and verifying are different operations

Missing this distinction produces security that looks present but is not. It accounts for the largest share of JWT incidents.

Decoding is just Base64

A JWT's header and payload are Base64URL encoded, not encrypted. Anyone can read them — this site's decoder can, and so can one line in a browser console. Therefore never put secrets in a JWT payload. National ID numbers, internal identifiers or detailed permissions in a token are a disclosure by themselves.

Verifying means checking the signature

Only by checking the third segment against a secret or public key can you conclude "we issued this and it has not been altered". Skip verification and read the payload, and an attacker's token with {"role":"admin"} is trusted as-is.

Check which library function you called

Most JWT libraries expose both decode() and verify(). decode() does not check the signature. The shorter name surfaces first in autocomplete and works fine in local tests, which is how it reaches production. Grepping for decode in review often catches it.

Do not log tokens

Because decoding is trivial, a token in a log file is a usable credential until it expires. If you log whole request headers, mask Authorization.

Why you cannot trust the header's alg

alg=none

The JWT spec includes a none algorithm meaning "unsigned". If verification follows the header's alg, an attacker can set it to none, strip the signature, and pass. This was a real vulnerability in older libraries.

Algorithm confusion (RS256 to HS256)

RS256 signs with a private key and verifies with the public key. If an attacker switches alg to HS256, the verifier uses that public key as an HMAC secret. The public key is public, so anyone can forge a valid signature.

The fix is one line

Pin the expected algorithm in code. Most libraries accept an argument like algorithms: ['RS256']. Allow only that, whatever the header claims. A token's header is input, not configuration.

Clock skew

A brand-new token is rejected

If the issuing server's clock runs a few seconds ahead, iat and nbf are in the future from the verifier's perspective, so the token is refused as "not yet valid". A few seconds of drift is common when NTP sync is loose in containers or VMs.

Requests near expiry fail intermittently

Conversely, a fast verifier sees a still-valid token as expired. This is a frequent cause of intermittent 401s that will not reproduce. If it only happens on certain instances, check the clocks first.

Allow tolerance, but not much

Most libraries offer clockTolerance or leeway; 30–60 seconds is conventional. Raising it to minutes keeps expired tokens alive that much longer, so it is no substitute for fixing NTP.

Short lifetimes, refresh tokens for renewal

A long access-token exp means a stolen token is usable for that long. Because JWT verification is stateless, there is no built-in way to revoke an issued token. Hence short lifetimes plus refresh tokens. This is also the source of "I logged out but the token still works".

What to do when you see "token expired"

  1. Paste the token into the JWT decoder and read exp, nbf and iat.
  2. Count the digits. Ten means seconds, thirteen means milliseconds. Reading milliseconds as seconds lands in the 1970s; reading seconds as milliseconds lands tens of thousands of years out.
  3. Convert with the timestamp converter and confirm whether the instant has actually passed.
  4. If it has not, check the verifier's clock. One date command is enough.
  5. If it still looks wrong, check whether the code calls decode instead of verify, and whether the allowed algorithm is pinned.
  6. If it is intermittent, see whether it only happens on certain instances. That is usually the clock.

Next: read claims in the JWT decoder, cross-check times with the timestamp converter, and confirm for yourself that the payload is merely Base64 using the Base64 encoder/decoder.