DEV Community

tommy
tommy

Posted on

I Built a JWT Decoder and Lost Half a Day to atob()

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 += '=';
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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


References

Top comments (0)