How to check JWT expiration
Read the exp claim from the payload and compare it with the current Unix time. date -d @1757503600 turns the integer into local time, and exp - now gives the seconds left. A negative number means expired. nbf and iat answer the opposite question, when the token starts being usable.
Why check this
Expiry drives the two flakiest classes of auth bug. A token that lives for hours keeps a revoked account signed in, and a token that lives for 60 seconds makes a long test suite fail at a random step with a 401 that looks like a server fault. Check exp when a suite starts failing in the middle instead of at login, after any change to the auth service configuration, and on staging sign-off where the lifetime often differs from production. The three time claims are integers of seconds since 1970, not milliseconds, which is the single most common reason a token looks 1000 times too young.
Prerequisites
- Node 22 or later for the arithmetic and the ISO conversion.
- GNU
datefor local time. Git Bash, macOS with coreutils, and any Linux have it. - A sample token. Build one with the generator on How to decode JWT; the one used here carries
iat: 1757500000andexp: 1757503600. - RFC 7519 section 4.1.4 for the definition of
expand the skew allowance.
Steps
- Step 1.
Print the three time claims with their UTC equivalents.
node -e " const p = JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString('utf8')); for (const k of ['iat', 'nbf', 'exp']) { console.log(k.padEnd(4), p[k] === undefined ? 'absent' : p[k] + ' ' + new Date(p[k] * 1000).toISOString()); } " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKgiat 1757500000 2025-09-10T10:26:40.000Z nbf absent exp 1757503600 2025-09-10T11:26:40.000Zexpminusiatis 3600, so this token was issued with a one hour lifetime. - Step 2.
Convert the same timestamp to local time, because incident reports and server logs use it.
date -d @1757503600 '+%Y-%m-%d %H:%M:%S %z'2025-09-10 14:26:40 +0300The offset at the end is the part to keep. A timestamp quoted without an offset is the reason two testers disagree about when a session ended.
- Step 3.
Compare
expwith the current time and print the verdict with an explicit skew allowance.node -e " const p = JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString('utf8')); const now = Math.floor(Date.now() / 1000); const skew = 60; console.log('now ', now, new Date(now * 1000).toISOString()); console.log('exp - now', p.exp - now, 'seconds'); console.log('verdict ', p.exp + skew < now ? 'expired' : 'valid'); " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKgnow 1789153454 2026-09-11T19:04:14.000Z exp - now -31649854 seconds verdict expiredYour
nowwill differ. The sign ofexp - nowis the answer, and the magnitude tells you whether the token missed by seconds or by a year. - Step 4.
Check the other end of the window by issuing a token whose
nbfsits 90 seconds ahead, then reading it at three skew settings.node -e " const c = require('crypto'); const now = Math.floor(Date.now() / 1000); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); const h = b64({ alg: 'HS256', typ: 'JWT' }); const p = b64({ sub: '42', iat: now, nbf: now + 90, exp: now + 3600 }); const claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); console.log('nbf', claims.nbf, '= now +', claims.nbf - now, 'seconds'); for (const skew of [0, 60, 120]) { console.log('skew', String(skew).padStart(3), '=>', claims.nbf - skew > now ? 'not yet valid' : 'accepted'); } "nbf 1789153556 = now + 90 seconds skew 0 => not yet valid skew 60 => not yet valid skew 120 => acceptedThe same token is rejected or accepted purely by the allowance the verifier applies.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| exp - now positive | The token is inside its window | Note the seconds left and whether the suite runs longer than that. |
| exp - now negative | Expired | Refresh, then check whether the lifetime matches the auth configuration. |
| exp absent | The token never expires on its own | Raise it. Revocation is now the only way to end the session. |
| exp around 1.7e12 | The issuer wrote milliseconds | The claim is seconds per RFC 7519. Divide by 1000 in the issuer, not in the client. |
| nbf ahead of now | Not yet valid | Compare the clocks of the issuer and the verifier before touching the code. |
Thresholds
is the clock skew allowance used in step 3. The RFC permits "some small leeway, usually no more than a few minutes", and leaves the number to the implementer, so it belongs in the test plan rather than in a library default.
Source: RFC 7519 section 4.1.4is the lifetime of the sample token, exp minus iat. Read that difference on a real token before deciding whether a suite can finish on one login.
Common mistakes
What to check next
- How to decode JWT: where the
expinteger comes from, and how to read the rest of the claims. - How to verify JWT signature: an unexpired token still proves nothing until the signature checks out.
- How to check JWT algorithm: a forged token carries whatever
expthe forger chose. - How to test API authentication: confirm the endpoint actually enforces the expiry you measured.
- Decode a JWT: paste a token and read
expas a date without leaving the page.
FAQ
How do I check the JWT token expiration time?
Decode the payload, read exp, and pass it to date -d @<value>. That is step 1 and step 2. The claim is seconds since 1970 in UTC, so the conversion needs no timezone argument.
How to check if a JWT token is expired or not?
Compare exp with Math.floor(Date.now() / 1000). A negative difference means expired. Step 3 prints both numbers so the result can be quoted in a bug report rather than asserted.
What if the token has no exp claim?
exp is optional in RFC 7519. A token without it stays valid until the signing key rotates or a deny list catches it. Report that as a finding, since logout then has no effect on the token itself.
Does an expired token still decode?
Yes. Expiry is a claim inside the payload, not a property of the encoding. The decode commands work forever, which is why the verdict has to be computed rather than observed.
Which clock decides, the client or the server?
The verifier's clock. Your local check predicts the answer, it does not produce it. When the two disagree, compare both hosts against the same time source before changing any code.
Verified
Verified by Maks Vernynode 22.23.2GNU coreutils 8.32
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: jwt decode header and payload, expiry, algorithm
- API security review
- API testing checklist
- All api checks checks
basic4 minpublished updated Maks Verny