← back to blog
4 min read
  • #jwt
  • #security
  • #web-dev
  • #tutorial

JWTs explained by decoding one

I take a real JWT apart piece by piece: header, payload, signature. What the signature actually proves, and why the payload is readable by anyone with no key at all.


Every auth tutorial hands you a JWT and moves on. "The server returns a token, attach it to your requests." Nobody stops to show what is actually inside the thing, so people treat it like an encrypted magic ticket. It is not encrypted, and it is not magic.

Here is a real token. I generated it for this post with a throwaway secret, so feel free to poke at it:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzQxMiIsIm5hbWUiOiJBbGRvIiwiYWRtaW4iOmZhbHNlLCJleHAiOjE3ODU1NDI0MDB9
.mMMyZMkkOhqbMtWwtuX8RdYDecONR_hwBHhGPOl9xp4

One line in real life. I broke it at the dots so you can see the three parts.

Three parts, two dots

A JWT is header.payload.signature. The first two parts are just JSON that has been base64url encoded. You can decode them with one line of Node:

node -e "console.log(Buffer.from(process.argv[1], 'base64url').toString())" eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

The header comes out as:

{"alg":"HS256","typ":"JWT"}

That is the token saying "I am a JWT, and my signature was made with HMAC-SHA256." Run the same command on the middle chunk and you get the payload:

{"sub":"user_412","name":"Aldo","admin":false,"exp":1785542400}

Those fields are called claims. sub is the subject, usually your user id. exp is the expiry in Unix seconds; 1785542400 is August 1, 2026 at midnight UTC. After that moment any honest server rejects the token, no database lookup needed.

Base64url is encoding, not encryption

Notice what I did not need to read that payload: a key, a secret, anything. Base64url is plain base64 with two characters swapped, plus becomes dash and slash becomes underscore, and the trailing = padding dropped, so a token can travel in URLs without escaping. You can spot it in the signature above: the underscore in R_hw is exactly why a strict base64 decoder sometimes chokes on tokens.

Encoding is reversible by anyone, on purpose. That leads to the rule I wish every tutorial put in bold: never put anything in a JWT payload you would not show to the person holding it. No passwords. No API keys. If a booking site for a dog groomer stuffed the customer's phone number and home address into the token, every log file, proxy, and browser extension that touches that token can read them. The payload is a postcard, not a sealed envelope.

What the signature actually proves

The third chunk is where the security lives. The server takes the literal string header.payload, runs HMAC-SHA256 over it with a secret only the server knows, and base64url encodes the 32 result bytes. That is the signature.

It proves exactly two things:

  • The token was issued by someone who holds the secret.
  • Not a single character has changed since it was issued.

Try it. Decode my payload, flip "admin":false to true, re-encode it, and staple the token back together. It still looks like a perfectly healthy JWT. But the signature no longer matches the new payload, so any server that verifies will throw it out. Without the secret you cannot produce a matching signature.

Just as important is what the signature does not prove:

  • It does not hide anything. Signed is not encrypted.
  • It does not prove the sender is the rightful owner. A JWT is a bearer token, so whoever holds it is "you." Steal the token, become the user until it expires.
  • It does not allow easy revocation. A signed token is valid until exp, full stop. If you need to kick one user out right now, you end up keeping a denylist on the server, which quietly brings back the server-side state that stateless tokens were supposed to remove. That tradeoff is real and there is no clean way around it.

One more sharp edge: the alg field in the header is attacker-supplied input. Old libraries would accept "alg":"none" and skip verification entirely. Modern libraries pin the algorithm, but it is worth checking that yours does.

Take apart your own tokens

This is honestly the fastest way to learn it. Log into some app, open devtools, find the token in local storage or the Authorization header, and decode the payload. You will find expiry times, roles, sometimes emails. It is also a good Security+ exercise: one string that contains encoding (base64url), keyed hashing (the HMAC signature), and zero encryption, which is exactly the kind of distinction SY0-701 questions love. I am still studying for that exam, and pulling apart real tokens taught me more than the textbook chapter did.

One warning before you go decode things: do not paste production tokens into random decoder websites. A live token is a credential, and pasting it hands that credential to whoever runs the site. Decode locally instead. That is why I put a JWT decoder in Aldo's Toolkit, free on both stores, no ads, everything stays on your phone: /app.

$ share

community rating

$ ls ./comments

sign in or create an account to rate and comment.

no comments yet, be first.