During development, I constantly need to check what's inside a JWT.
Every time, I'd open jwt.io, paste the token, read the payload, and switch back to the editor. This back-and-forth was surprisingly tedious. And pasting auth tokens into an external site always felt a bit uncomfortable.
"Let me build a decoder that works locally."
The finished decoder is here. It auto-detects JWTs from your clipboard and decodes them.
👉 jwt.puremark.app
A JWT is just three parts joined by .. Base64-decode them and you can see the contents. Should be easy — or so I thought.
Trap 1: atob() Doesn't Work
I extracted the payload part of a JWT and passed it to JavaScript's atob().
const payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
const decoded = atob(payload);
// => DOMException: String contains an invalid character
It doesn't work.
After investigating, I found the cause. JWT uses Base64url, not Base64. They look similar but are different.
| Standard Base64 | Base64url | |
|---|---|---|
| 62nd character | + |
- |
| 63rd character | / |
_ |
The characters are replaced with URL-safe alternatives. atob() only accepts standard Base64, so passing Base64url directly throws an error.
The fix is just two replace calls.
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(base64);
// => {"sub":"1234567890","name":"John Doe","iat":1516239022}
It works. "Is that all?" I thought — but it's the kind of trap you'd be stuck on forever if you didn't know about it.
Trap 2: The Padding Problem Strikes Again
I felt safe after fixing Trap 1. Then I tried a different JWT and got the same error.
DOMException: String contains an invalid character
Same error. But this time there were no - or _ characters.
The cause was padding. Standard Base64 pads strings to a multiple of 4 with =. But Base64url omits the =. atob() sometimes rejects input without proper padding.
The fix is straightforward. Add = based on the remainder when dividing the string length by 4.
const pad = base64.length % 4;
if (pad === 2) base64 += '==';
else if (pad === 3) base64 += '=';
Remainder of 2 → ==, remainder of 3 → =. That's it. But the time I spent testing every JWT and wondering "why does only this one fail?" was definitely half a day.
UTF-8 Support: Enter TextDecoder
On top of that, JWTs with Japanese characters in the payload produced garbled text. atob() treats everything as Latin-1, breaking multibyte characters.
The final decode function looks like this:
function base64UrlDecode(str) {
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad === 2) base64 += '==';
else if (pad === 3) base64 += '=';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
Trap 1 (character conversion) + Trap 2 (padding) + UTF-8 support. All together, 13 lines. It took half a day to arrive at these 13 lines.
Design Decision: No Signature Verification
"A JWT decoder that doesn't verify signatures?"
Correct. The reason is clear: the purpose of a decoder is to inspect the contents. Signature verification is the server's job. Holding secret keys on the client side is a security risk, and the algorithms vary widely.
However, there's one absolute line to draw:
Decode ≠Trust
Just because you can decode a JWT and see its contents doesn't mean you should trust them. Without signature verification, tampering cannot be detected. A decoder is strictly a development inspection tool. If you use decoded results for authentication or authorization decisions, always verify signatures on the server side.
Bonus: Auto Claim Descriptions and Expiry Badges
Once decoding works, the next thing you want is "what does this claim mean?" exp, iat, iss — JWT standard claims use abbreviations that are hard to remember.
I added descriptions for 15 major claims.
| Claim | Description |
|---|---|
iss |
Issuer |
sub |
Subject |
aud |
Audience |
exp |
Expiration Time |
nbf |
Not Before |
iat |
Issued At |
jti |
JWT ID |
For timestamp claims like exp, nbf, and iat, I display not just Unix seconds but also ISO 8601 format + relative time ("3 hours ago", "in 5 days").
Expiry badges are also implemented. Green for valid, red for expired, yellow for not-yet-valid. No need to paste into jwt.io — check your token's status at a glance at jwt.puremark.app.
Decoded payloads can also be formatted with JSON Formatter or the original encoded string can be checked with Base64 Decoder. Cross-tool navigation links are included.
Before / After
| Before | After | |
|---|---|---|
| JWT inspection | Copy-paste to jwt.io. Token sent to external site | Runs locally. Auto-detects from clipboard |
atob() |
Pass Base64url directly → error | Insert -→+, _→/ conversion |
| Padding | Some JWTs fail mysteriously | Check % 4 remainder and append =
|
| Claims | Look up exp every time |
Auto descriptions + relative time display |
The lessons from half a wasted day are condensed into a 13-line function. I hope this article saves you that half day.
JWT Decoder → jwt.puremark.app
Top comments (0)