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

Steps

  1. 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_4HhMay9pVKg
    
    iat  1757500000  2025-09-10T10:26:40.000Z
    nbf  absent
    exp  1757503600  2025-09-10T11:26:40.000Z

    exp minus iat is 3600, so this token was issued with a one hour lifetime.

  2. 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 +0300

    The 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.

  3. Step 3.

    Compare exp with 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_4HhMay9pVKg
    
    now       1789153454 2026-09-11T19:04:14.000Z
    exp - now -31649854 seconds
    verdict   expired

    Your now will differ. The sign of exp - now is the answer, and the magnitude tells you whether the token missed by seconds or by a year.

  4. Step 4.

    Check the other end of the window by issuing a token whose nbf sits 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 => accepted

    The 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

60 s

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.4
3600 s

is 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.

Source: measured in step 1 of this page

Common mistakes

Sign: A token issued a second ago is reported as expired in the year 56000.Cause: The issuer wrote exp in milliseconds. Multiplying by 1000 again in the client hides it, and the next service that reads the claim correctly starts rejecting every token.
Sign: A fresh token is refused by one service and accepted by another.Cause: The issuer clock runs ahead of the verifier clock, so iat and nbf sit in the verifier's future. The skew allowance in step 4 covers it, an unsynchronised host does not.
Sign: The suite passes alone and fails in CI at a random step with 401.Cause: The token lifetime is shorter than the suite runtime. The failing step is whichever one crosses exp, which is why the failure moves between runs.

What to check next

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.

basic4 minpublished updated Maks Verny