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
- Node 22 or later.
Buffer.from(s, 'base64url')handles the JWT alphabet and the missing padding in one call. See the Node buffer documentation. - A shell with
cut,trandbase64. - RFC 7519 for the meaning of the registered claims.
- A token you made yourself. A production token pasted into an online decoder is a live credential handed to a third party, so generate one as in step 1.
Steps
- 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_4HhMay9pVKgThe secret and the timestamps are fixed, so this exact token comes out on any machine.
- 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_4HhMay9pVKgThree lines means header, payload, signature. Two lines means the signature was dropped somewhere in transport.
- 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
trstage is not decoration. JWT uses the URL-safe alphabet, where-stands for+and_stands for/, and plainbase64rejects both. - 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_4HhMay9pVKgheader {"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
What to check next
- How to verify JWT signature: decoding shows the claims, verification shows whether they are the issuer's.
- How to check JWT expiration: turn the
expinteger you just read into a time and a verdict. - How to check JWT algorithm: the
algfield from step 4 decides the whole attack surface. - How to test API authentication: the round trip that puts the token to work against a real endpoint.
- Decode a JWT: the same split and decode in the browser, with nothing leaving the page.
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.
Related on this site
- Checker: jwt decode header and payload, expiry, algorithm
- API security review
- API testing checklist
- All api checks checks
basic3 minpublished updated Maks Verny