JWT expiration is controlled by the exp claim, a NumericDate in seconds since the Unix epoch. Compare exp with the current time in seconds, check nbf and iat, and account for small clock differences. Decoding a JWT is not verification: always verify the signature and algorithm before trusting any claim.
- exp, nbf, and iat are NumericDate values in seconds, not milliseconds.
- A token can be rejected because it is expired, not yet valid, signed with the wrong key, or using an unexpected algorithm.
- Decoding the payload only reveals claims; it does not prove the token is authentic.
- Allow a small clock skew, typically 30 to 120 seconds, instead of requiring exact time agreement.
- Never log full tokens in production; log a fingerprint or the claims you actually need.
A JWT failure looks like a single error, but it can come from four different places: the token is expired, it is not valid yet, the signature does not match, or the application expects a different algorithm. Fixing the wrong layer wastes time and can weaken security.
This guide explains the time-based claims, shows how to inspect a token without trusting it, and walks through the most common expiration and verification errors.
The three time claims
A JWT payload is a JSON object. The registered time claims are:
| Claim | Meaning | Unit |
|---|---|---|
iat | Issued at | Seconds since Unix epoch |
nbf | Not valid before | Seconds since Unix epoch |
exp | Expiration time | Seconds since Unix epoch |
A token is accepted only when:
now >= nbf (if present)
now < exp (if present)
The values are seconds, not milliseconds. A token with exp: 1761500000000 is not valid until roughly the year 57770; a token with exp: 1761500000 expires on a normal human timescale. Mixing the two units is the most common cause of "already expired" and "valid forever" bugs.
RFC 7519 defines NumericDate as seconds since 1970-01-01T00:00:00Z UTC, ignoring leap seconds, and permits non-integer values. Most libraries compare the claim as a number, so Date.now() / 1000 works without rounding, but floor it when logging so the arithmetic is readable. Every one of these claims is optional: a token without exp never expires, and the major verification libraries will accept it. For access tokens, require exp at the verifier instead of trusting each issuer to include it. A missing exp is a configuration bug, not a token that happens to live forever.
Decode before you verify
The header and payload are base64url-encoded, not encrypted. You can read them without the secret:
function decodePart(part) {
const padded = part.replace(/-/g, "+").replace(/_/g, "/")
.padEnd(Math.ceil(part.length / 4) * 4, "=");
return JSON.parse(atob(padded));
}
const [headerPart, payloadPart] = token.split(".");
console.log(decodePart(headerPart));
console.log(decodePart(payloadPart));
For a quick visual inspection, paste the token into a JWT decoder. Compare exp with the current Unix time using a timestamp converter. The decoder should warn you that the token is unverified.
Never make an authorization decision from a decoded-but-unverified token. The payload is attacker-controlled until the signature is checked.
Two decoding failures appear before the claims do. JWT segments use base64url without padding, and calling atob directly on a segment whose length is not a multiple of four throws DOMException: Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded. The helper above pads first, which is why it works. The second failure is subtler: atob returns a binary string where each character is one byte, and JSON.parse on that string produces mojibake for non-ASCII values such as a Chinese or accented display name. The claims you normally debug (exp, nbf, alg) are ASCII and hide the problem. When the actual text matters, convert the binary string to bytes and decode with TextDecoder.
Error 1: "jwt expired"
The token's exp is in the past relative to the verifier's clock.
Debugging steps:
- Decode the payload and read
exp. - Convert
expto a human-readable date. - Compare it with the server's current time, not your laptop's time.
- Check whether the issuer uses seconds or milliseconds.
- Check whether the access token lifetime is shorter than the client assumes.
const payload = decodePart(token.split(".")[1]);
const now = Math.floor(Date.now() / 1000);
console.log({
exp: payload.exp,
expDate: new Date(payload.exp * 1000).toISOString(),
now,
nowDate: new Date(now * 1000).toISOString(),
secondsRemaining: payload.exp - now
});
If the token is genuinely expired, the fix is usually a refresh flow, not a longer expiration. Very long-lived access tokens increase the impact of a leaked token.
The error shape tells you which layer rejected the token. jsonwebtoken throws TokenExpiredError with name: "TokenExpiredError", message: "jwt expired", and an expiredAt Date property. jose throws JWTExpired with code: "ERR_JWT_EXPIRED". An OAuth resource server usually returns a 401 with WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired", which is an HTTP response, not a parser exception.
Before blaming the issuer, compare clocks without SSH access. curl -sI https://api.example.com/health prints a Date header generated by the server; if it is minutes ahead of your laptop, the token is probably fine and the verifier's clock is wrong. A digit count is an even faster unit check: current Unix timestamps in seconds have ten digits until 2286, while millisecond timestamps have thirteen. A ten-digit exp is seconds; a thirteen-digit value came from code that forgot to divide by 1000.
Error 2: "jwt not active" or "TokenNotYetValid"
The token's nbf is in the future, or iat is later than the verifier's clock. This commonly happens when:
- The issuer's clock is ahead of the verifier's clock.
- The token was created for a future activation window.
- A test fixture uses a hard-coded future date.
Compare nbf and iat with the current time. If the difference is a few seconds, configure a small clock tolerance in the verification library rather than removing the claim.
The boundary is inclusive on one side and exclusive on the other: a token is valid at exactly nbf and invalid at exactly exp. Libraries that compare with > instead of >= differ only for the one second at the boundary, and any tolerance of more than a second hides the distinction. The interesting cases are minutes, not milliseconds.
A memorable cause of future-dated tokens is a VM restored from a snapshot or a host whose clock source is broken. If one pod's clock is five minutes ahead, every token it signs looks not-yet-valid to the rest of the fleet for five minutes, and then the failures stop on their own. That self-healing pattern is the signature of clock skew rather than a code defect, and it is the reason to check the Date header before reading the signing code.
Error 3: "invalid signature"
The signature check failed. The token may be expired and re-signed, signed with a different secret, signed with an asymmetric key when the verifier expects a shared secret, or modified in transit.
Check the header:
{
"alg": "HS256",
"typ": "JWT"
}
Then confirm:
- The verifier uses the same algorithm as the issuer.
- The secret or public key matches the environment (development vs production).
- The token was not truncated by a proxy or log pipeline.
- The key was not rotated without updating the verifier.
Do not "fix" a signature error by disabling verification. That converts a security control into a suggestion.
Two mundane causes explain many signature failures. A secret loaded with JWT_SECRET=$(cat secret.txt) includes the file's trailing newline, while the issuer may have used the value without it; the HMAC input differs by one byte and every signature fails. Print the byte length of the secret on both sides, or load it through a library that trims. The second cause is key rotation: when the issuer adds a key, tokens carry a kid header. A verifier that ignores kid and always uses the first key rejects every token signed by the new one. Fetch keys from the issuer's JWKS endpoint, select by kid, and refresh the cache when an unknown kid appears, with rate limiting so forged headers cannot trigger a request storm.
When debugging a symmetric signature, remember that the signing input is the exact bytes of base64url(header) + "." + base64url(payload), not the decoded JSON. Re-serializing the payload changes whitespace or key order and produces a different signature even when the claims look identical.
Error 4: "invalid algorithm" or algorithm confusion
The alg header tells the verifier which algorithm to use. Accepting any algorithm from the token is dangerous. Enforce an allowlist on the verifier:
jwt.verify(token, key, { algorithms: ["RS256"] });
This prevents algorithm-confusion attacks where an attacker changes alg to none or switches between symmetric and asymmetric verification. RFC 8725 recommends explicit algorithm allowlists and key-type checks.
Algorithm errors also appear when the value is not a JWT at all. A JWE has five dot-separated segments, and the second segment is an encrypted key, not a JSON payload; decoding it as a JWT payload fails at base64url or JSON parsing. Count the segments before decoding. With asymmetric keys, the algorithm must also match the key type: an RSA public key cannot verify an ES256 signature, and an EC key cannot verify RS256. A JWKS document often contains several keys, so select by both kid and alg; otherwise a type mismatch is reported as an invalid signature and sends you looking for a key problem that does not exist.
Clock skew and distributed systems
Distributed systems rarely share a perfect clock. Allow a small tolerance:
jwt.verify(token, key, {
algorithms: ["RS256"],
clockTolerance: 60
});
Sixty seconds is a reasonable starting point. If you routinely need minutes of tolerance, fix the clock synchronization instead. Large tolerances extend the window in which an expired token is still accepted.
Measure skew before choosing a number. On Linux, timedatectl and chronyc tracking report the current offset and whether NTP is synchronized; a healthy cloud VM stays within milliseconds, while a host with NTP blocked can drift by seconds per day. Pick a tolerance that covers the observed maximum rather than a round number copied from a blog post. The security cost is real: with a 120-second tolerance, a stolen token stays usable for two minutes past exp, and a token with a five-minute lifetime is effectively valid for seven. Keep the tolerance small relative to the token lifetime, and alert when the measured skew starts to grow.
Access tokens and refresh tokens
A common design uses:
- A short-lived access token, often 5 to 15 minutes.
- A longer-lived refresh token, often days or weeks.
- A refresh endpoint that issues a new access token after validating the refresh token.
When the access token expires, the client should refresh it rather than forcing a new login. The refresh token must be stored more carefully, rotated on use, and revocable. An expired access token is not a security incident; an indefinitely valid refresh token is a much larger risk.
Refresh flows fail in a characteristic way under concurrency. Several browser tabs or parallel requests notice expiry at once, all call the refresh endpoint, and rotation invalidates the refresh token after the first success, so the rest receive invalid_grant and the user is signed out. Collapse refreshes into one in-flight promise per client, or refresh proactively at around 80 percent of the access token lifetime so the race becomes rare. On the server side, rotation with reuse detection is worth the extra storage: keep a hash of the current refresh token, mark it used on exchange, and revoke the whole token family if an old one appears again. That turns a stolen refresh token from permanent access into a detectable event.
JWT is not always the right boundary. A server-side session can be revoked immediately and needs no denylist; a self-contained JWT stays valid until exp unless you build one. If instant revocation is a hard requirement, either keep access tokens very short or use opaque tokens backed by a session store.
A practical debugging checklist
- Split the token into three parts and confirm all three exist.
- Decode the header and payload without verifying.
- Read
alg,exp,iat, andnbf. - Convert the timestamps to dates and compare them with the verifier's clock.
- Confirm the expected algorithm and key.
- Verify the signature with the same library and configuration used in production.
- Check whether a proxy, gateway, or log pipeline altered the token.
- Reproduce the failure with a minimal request.
- Count the segments: three is a JWT, five is a JWE and needs different handling.
- Compare the verifier's clock with the issuer's
Dateheader, then with the token'siat.
Use the JWT signer to create a test token with known claims, and the HMAC generator to inspect the signing input when debugging a symmetric signature. Keep test secrets out of production and out of logs.
What not to do
- Do not store secrets in the JWT payload.
- Do not use
alg: nonein production. - Do not accept a token just because it decodes.
- Do not extend expiration to hide a refresh bug.
- Do not log the full token; log a short fingerprint and the relevant claims.
- Do not keep long-lived tokens in
localStoragewhen anHttpOnly,Secure,SameSitecookie can carry the session; any XSS can read local storage. - Do not treat the
typheader as a security control. It is optional, frequently absent, and trivially edited.
Expiration is a reliability feature and a security boundary at the same time. Treat the timestamps as data to inspect, and treat the signature as the only reason to trust that data.
Frequently asked questions
What is the difference between exp, iat, and nbf?
exp is the expiration time, iat is the issued-at time, and nbf is the not-before time. All three are NumericDate values measured in seconds since 1970-01-01T00:00:00Z. A token is valid only when the current time is after nbf and before exp.
Why is my JWT already expired right after I created it?
The most common causes are using milliseconds instead of seconds for exp, signing with a very short lifetime, or a server clock that is ahead of the client clock. Decode the payload and compare exp with the current Unix timestamp in seconds.
Should I trust the claims after decoding a JWT?
No. Decoding only base64url-decodes the header and payload. Anyone can edit the payload and re-encode it. Verify the signature with the correct key and algorithm before using any claim for authorization.
How much clock skew should I allow?
A tolerance of 30 to 120 seconds is common for distributed systems. Choose the smallest value that covers your infrastructure's observed clock drift, and monitor it rather than increasing it indefinitely.
Does an expired JWT mean the user is logged out?
Not necessarily. Many systems use short-lived access tokens and longer-lived refresh tokens. An expired access token can be refreshed without forcing the user to sign in again, provided the refresh token is still valid and has not been revoked.