How to decode JWT

Split the token on its two dots and base64url-decode the middle segment. cut -d. -f2 | tr '_-' '/+' | base64 -d prints the payload as JSON with every claim readable. No key and no network call are involved, because a JWT is signed rather than encrypted, so anyone holding the token reads it.

Why check this

You read a token when an endpoint answers 403 for an account that should pass, or when a session ends sooner than the test plan says. The payload names the subject, the role and the issuer, and one of those three is usually wrong. Run this during auth regression and whenever a login response changes shape after a deploy. It catches the case where the login service issues a token whose role claim stays user while the front end routes that account into an admin screen, a defect that reads like a permissions bug in the API and lives in the token factory.

Prerequisites

Steps

  1. Step 1.

    Build a sample HS256 token so the rest of the page is reproducible.

    node -e "
    const c = require('crypto');
    const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
    const header = b64({ alg: 'HS256', typ: 'JWT' });
    const payload = b64({ sub: '42', role: 'tester', iss: 'h2check-local', iat: 1757500000, exp: 1757503600 });
    const sig = c.createHmac('sha256', 'h2check-demo-secret').update(header + '.' + payload).digest('base64url');
    console.log(header + '.' + payload + '.' + sig);
    "
    
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg

    The secret and the timestamps are fixed, so this exact token comes out on any machine.

  2. Step 2.

    Split the token into its three segments to confirm the shape before decoding anything.

    echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg | tr '.' '\n'
    
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9
    1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg

    Three lines means header, payload, signature. Two lines means the signature was dropped somewhere in transport.

  3. Step 3.

    Decode the middle segment in the shell.

    echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg | cut -d. -f2 | tr '_-' '/+' | base64 -d
    
    {"sub":"42","role":"tester","iss":"h2check-local","iat":1757500000,"exp":1757503600}

    The tr stage is not decoration. JWT uses the URL-safe alphabet, where - stands for + and _ stands for /, and plain base64 rejects both.

  4. Step 4.

    Decode the header and the payload together in Node, which accepts base64url and unpadded input.

    node -e "
    const [h, p] = process.argv[1].split('.');
    const j = (s) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));
    console.log('header ', JSON.stringify(j(h)));
    console.log('payload', JSON.stringify(j(p), null, 2));
    " eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJoMmNoZWNrLWxvY2FsIiwiaWF0IjoxNzU3NTAwMDAwLCJleHAiOjE3NTc1MDM2MDB9.1LVq5DRuwVY-C2EhoGUUYJA9qmygc_w_4HhMay9pVKg
    
    header  {"alg":"HS256","typ":"JWT"}
    payload {
    "sub": "42",
    "role": "tester",
    "iss": "h2check-local",
    "iat": 1757500000,
    "exp": 1757503600
    }

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Readable JSON with sub, iss and exp | A normal signed JWT | Read the claims your test depends on, then verify the signature separately. | | {"alg":"HS256","typ":"JWT"} in segment one | The token is an HS256 JWS | Confirm the service pins that algorithm before trusting the payload. | | Five segments instead of three | This is a JWE, encrypted, not a JWS | Stop here. The payload needs the recipient key, and no local decode will show it. | | base64: invalid input | The segment length is not a multiple of four | Decode with Node, or append = until the length divides by four. | | Binary noise instead of JSON | You decoded the signature segment | The signature is raw bytes. Use field two, not field three. |

Common mistakes

Sign: base64 -d prints the JSON and then base64: invalid input on the same run.Cause: JWT strips the = padding, so a segment whose length is not a multiple of four makes GNU base64 decode what it can and exit 1. The output looks correct while the exit code reports failure, which breaks any script that tests it.
Sign: atob in the browser console throws InvalidCharacterError on a token that decodes fine in the shell.Cause: atob tolerates missing padding but rejects the URL-safe characters - and _. Replace them with + and / first, or the console reports a corrupt token that is not corrupt.
Sign: The payload reads correctly, so the token is treated as trusted.Cause: Decoding runs without any key, so a payload edited by hand decodes exactly as cleanly. Reading a claim proves nothing about where it came from until the signature is checked.

What to check next

FAQ

How to decode a JWT token without a library?

The commands above are the whole procedure. A JWT is dot-separated base64url, so cut and base64 cover it, and Node covers the padding. A library adds signature checking, which is a separate job.

How to decode the JWT payload only?

Take field two: cut -d. -f2. Field one is the header, field three is the signature and decodes to raw bytes rather than text. Nothing else in the token holds claims.

How to decode a JWT locally instead of online?

Run step 3 or step 4 on your own machine. An online decoder receives the token, and a token is a working credential until it expires. Local decoding needs no network at all.

How do I check a JWT token in the browser?

Open DevTools, Application tab, Local Storage or Cookies, and copy the token value. In the Console tab, replace - with + and _ with /, then call atob on the middle segment.

Does decoding a JWT need the secret?

No. The secret signs and verifies, it never encrypts. That is the point of the check on the next page, where the same token is accepted or rejected against a key.

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.

basic3 minpublished updated Maks Verny