Someone on your team pastes a token into Slack and asks why this user keeps getting a 403. The token looks like a wall of random characters, but it isn't encrypted. It's three chunks of Base64URL glued together with dots, and you can read most of it in about ten seconds.
This post shows you how to read one, what each part means, and the mistake I see most often in code review: treating a decoded token as a trusted one.
What a JWT Actually Is
A JSON Web Token is defined in RFC 7519. Almost every JWT you'll meet is a signed token (the signing format is JWS, RFC 7515), and it has three parts separated by dots:
Here's a real one. It was signed with HS256 using the secret north-wind-7731, which I'm only telling you because it's a demo:
- Header (
eyJhbGci...): which algorithm signed the token. Here it'sHS256. - Payload (
eyJzdWIi...): the claims, meaning who the user is and when the token runs out. - Signature (
zVEWwTQd...): an HMAC of the first two parts. It proves nobody changed them, but only to someone who holds the secret.
You'll notice both the header and the payload start with eyJ. That's simply what {" followed by a letter looks like once it's encoded, and since both parts are JSON objects, nearly every JWT starts that way. Once you know it, you'll start spotting tokens in logs, cookies and URLs.
Decoding It Yourself
Base64URL is ordinary Base64 with two characters swapped (- for + and _ for /) and the trailing = padding dropped, as section 5 of RFC 4648 describes. That's why pasting a JWT segment straight into the browser's atob() sometimes throws an error. Node understands base64url natively, so decoding the payload is three lines:
With our demo token, that prints:
If you'd rather skip the code, paste the whole token into our JWT Decoder. It splits the token, decodes the header and payload, and shows both as formatted JSON:
That last line is the decoder being honest with you, and it's the most important thing on the page. More on that in a minute.
Reading the Claims
The payload is where the useful information lives. The registered claim names in RFC 7519 are short on purpose, so here's what the common ones mean:
sub: the subject, usually a user ID. Here it'sSUB-1001.iat: issued at, in seconds since 1 January 1970 (Unix time).exp: expires at, in the same format. After this moment the token must be refused.nbf: not before. The token isn't valid until this time.issandaud: who issued the token and who it's meant for. Check both if more than one service shares a signing key.
The decoder shows iat and exp exactly as they appear in the token, as plain numbers. To turn 1793959200 into a date, drop it into the Timestamp Converter: it's 6 November 2026 at 10:00 UTC, exactly one hour after iat. A one-hour access token is a very common setup.
One trap catches almost everyone once. JWT times are in seconds, but JavaScript's Date.now() returns milliseconds. Compare the two directly and every token will look expired, because you're comparing a 13-digit number with a 10-digit one. Divide by 1000 first.
Decoding Is Not Verifying
Here's the part that causes real security bugs. Decoding a JWT needs no key at all. So anyone can decode one, and anyone can also make a new one. Watch what happens when I change role from billing-admin to admin, re-encode the payload and keep the original signature:
Paste that into any decoder and it decodes perfectly, showing "role": "admin". Nothing about decoding tells you the token was tampered with. Only the signature check can.
I've reviewed code that did exactly this: decode the token, read role, grant access. It passes every test, because every test token is genuine. Then someone edits a token in their browser's dev tools and they're an admin.
So your server has to verify the token on every request. For HS256 that means recomputing the HMAC of header.payload with the shared secret and comparing it with the signature. Here's a small Node version, so you can see there's no magic involved:
Run it against our demo tokens and you get exactly what you'd hope for:
Two details in that snippet are worth copying even if you never use it. crypto.timingSafeEqual compares in constant time, so an attacker can't work out the signature byte by byte by measuring how quickly you reject each guess. And the expiry check runs after the signature check, because an exp value means nothing until you know nobody changed it.
In production, use a maintained library such as jose or jsonwebtoken instead of a hand-rolled function. The OWASP JSON Web Token Cheat Sheet is a good checklist for the settings that matter.
Three Mistakes I Keep Seeing
- Putting secrets in the payload. The payload is only encoded, not encrypted. Anyone holding the token can read it, so leave out passwords, card numbers and anything else you wouldn't print in a log.
- Letting the token choose its own algorithm. Read the expected algorithm from your own config, not from the token's header. Otherwise an attacker can send
"alg": "none"and hope your code skips the check. - Long expiry times. If a token leaks,
expis the only thing limiting the damage. An hour is reasonable for an access token. A week is a long time for something anyone can replay.
Is It Safe to Paste a Token Into a Decoder?
It's a fair question, because a live token is a credential. Our JWT Decoder does the decoding in your browser, so the token isn't sent anywhere. Even so, the safest habit is to paste tokens that have already expired, or ones from a test environment. If a colleague needs to see what's inside, send them the decoded payload, not the token itself.
If you only need one segment, the Base64 URL Decoder will decode it on its own, and the JSON Formatter tidies up a payload you've copied out of a log.
Before You Close This Tab
A JWT is three Base64URL chunks: header, payload and signature. You can decode the first two with nothing but a browser, which makes them great for debugging and useless as proof of anything. Decode to read, verify to trust, and compare exp in seconds, not milliseconds.