DEV Community

Mind vs Drive
Mind vs Drive

Posted on

How to Decode a JWT (and What You Can and Can't Learn From It)

If you've built anything with login in the last few years, you've handled a JSON Web Token. It shows up in an Authorization: Bearer ... header, in a cookie, or in a response from your auth provider, and it looks like a long random string. It isn't random at all. Most of it is plain JSON that anyone can read in a few seconds, and knowing how to read it saves a lot of debugging time.

This post walks through what a JWT is made of, decodes one by hand, and covers the mistakes I see most often. At the end I mention a free decoder I built for this.

The three parts of a JWT

A JWT is three pieces joined by dots:

header.payload.signature
Enter fullscreen mode Exit fullscreen mode
  • Header: a small JSON object saying which algorithm signed the token (for example HS256 or RS256) and the token type.
  • Payload: a JSON object of claims, which are facts about the user and the token, such as a user id, a role, when it was issued and when it expires.
  • Signature: a cryptographic signature over the header and payload, made with a secret or private key, so the server can tell whether anyone changed them.

The header and payload are not encrypted. They're encoded with Base64URL, a variant of Base64 that swaps + for - and / for _ and drops the = padding so the result is safe in URLs and headers.

A worked example

Here's a token I made for this post (the signature part is a placeholder):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDI0IiwibmFtZSI6IkFzaGEiLCJyb2xlIjoiZWRpdG9yIiwiaWF0IjoxNzkzNzc5MjAwLCJleHAiOjE3OTM3ODI4MDB9.SIGNATURE
Enter fullscreen mode Exit fullscreen mode

Split it on the dots and decode the first part from Base64URL:

{"alg":"HS256","typ":"JWT"}
Enter fullscreen mode Exit fullscreen mode

Decode the second part:

{"sub":"1024","name":"Asha","role":"editor","iat":1793779200,"exp":1793782800}
Enter fullscreen mode Exit fullscreen mode

Now the claims mean something:

  • sub is the subject, usually the user id: 1024.
  • role is a custom claim the app added: editor.
  • iat (issued at) is a Unix timestamp: 1793779200 is 4 Nov 2026, 08:00 UTC.
  • exp (expires) is 1793782800, exactly 3,600 seconds later, so the token lives for one hour.

You can do the decoding yourself in the browser console:

const part = token.split('.')[1];
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
console.log(JSON.parse(json));
Enter fullscreen mode Exit fullscreen mode

atob expects standard Base64, which is why the - and _ get swapped back first. It's usually fine with missing padding, but some decoders aren't, so add = until the length is a multiple of 4 if you hit an error.

Or in a terminal:

echo 'eyJzdWIiOiIxMDI0IiwibmFtZSI6IkFzaGEiLCJyb2xlIjoiZWRpdG9yIiwiaWF0IjoxNzkzNzc5MjAwLCJleHAiOjE3OTM3ODI4MDB9' | tr '_-' '/+' | base64 -d
Enter fullscreen mode Exit fullscreen mode

(base64 -d may complain about the missing padding at the end, but it still prints the JSON.)

Decoding is not verifying

This is the most important thing to understand. Decoding tells you what the token says. Only verifying the signature with the right key tells you whether to believe it.

Anyone can make a token with "role":"admin" in the payload. If your server reads the payload without checking the signature, you've built an authentication bypass. Always verify with your library (jsonwebtoken, jose, PyJWT and so on), with the algorithm pinned on the server side rather than taken from the token header.

That's also why an online decoder will show you the header and payload but warn that the signature can't be checked. Without your secret or public key, no tool can check it, and you shouldn't paste a production secret into a website anyway.

Mistakes worth catching

1. Putting secrets in the payload. Because the payload is only encoded, an email address, an internal id, or worse, a password or API key in there is readable by anyone who sees the token, including browser extensions and log files. Keep payloads to what the client is allowed to know.

2. Trusting alg: none. A header with "alg":"none" means the token isn't signed. Old libraries sometimes accepted these. Reject them.

3. Confusing seconds and milliseconds. JWT timestamps are in seconds. JavaScript's Date.now() returns milliseconds. If you compare exp to Date.now() directly, every token looks expired for the next 50,000 years, or never expires, depending on which way you get it wrong. Use Date.now() / 1000.

4. Clock skew. If a token is "expired" right after login, check the server clocks. Most libraries allow a small leeway (30 to 60 seconds) for exactly this reason.

5. Huge tokens. Every claim you add travels with every request. A token with a long permissions list can push headers past proxy limits (often 8 KB). If your token is several kilobytes, move data to the server and look it up by sub.

When to decode a JWT

  • Debugging a 401: check whether exp has passed, whether aud matches your API, and whether iss is the issuer you expect.
  • Integrating a new auth provider: see which claims it actually sends before writing code that depends on them.
  • Reviewing security: make sure nothing sensitive is sitting in the payload.
  • Learning: seeing real tokens makes the spec much easier to follow.

A free tool for this

I got tired of writing the same console snippet, so I built a free JWT decoder at base64decodetool.com/jwt. You paste a token and it shows the header, payload and standard claims (with exp and iat turned into readable dates), flags alg: none and expired tokens, and runs entirely in your browser, so the token isn't uploaded anywhere. The same site has plain Base64 and Base64URL encoders and decoders if you need to look at one part on its own.

Whatever tool you use, remember the rule: decode to understand, verify to trust.

Top comments (0)