DEV Community

Max
Max

Posted on • Originally published at orthogonal.info

Pasting a JWT Into an Online Base64 Decoder Is a Credential Leak — Here's the Browser-Only Fix

Last month I watched a teammate debug an auth bug by pasting a production JWT into the first "base64 decode online" result on Google. The token was a live bearer credential — valid for another 50 minutes, signed for our payments service. He pasted it into a text box on a server he'd never heard of, hit decode, and read the payload. The bug got fixed. The token also got handed to a stranger's web server, where it sat in request logs neither of us will ever see.

That's the quiet problem with online base64 tools, and it's worth understanding why it happens — plus the two things even experienced devs get wrong when they try to skip the tool and just use the browser console.

Why pasting a JWT into a random decoder is a credential leak

A JWT is three base64url segments joined by dots: header, payload, signature. The first two decode to plain JSON. The third is the HMAC or RSA signature. Decoding it doesn't "crack" anything — but that misses the point: the whole string is the credential. If your decoder runs server-side, you just POSTed a working bearer token to a third party.

Most "free online" decoders are server-side. You can tell because:

  • they still work with JavaScript disabled, or
  • the network tab shows a request firing on every keystroke.

Some are honest hobby projects. Some are ad-funded and log everything. You have no way to know which, and "it's probably fine" is not a security model when the input is a live session token, an API key in a config blob, or a base64-encoded .env file.

The fix isn't a better-behaved server. It's not using a server at all. atob, btoa, and TextDecoder have shipped in every browser for years — the decode can happen entirely in your tab, with zero requests carrying your data. Open the network tab while a properly client-side tool decodes a 2 MB file and you'll see exactly that: nothing leaves.

The URL-safe gotcha that breaks the browser console

Here's the part that trips up even experienced devs. You might think "I don't need a tool, I'll just run atob() in the console." Try it on a real JWT segment and watch it throw.

// A JWT payload segment is base64URL, not standard base64
atob("eyJzdWIiOiIxMjM0NTY3ODkwIn0")  // ok here

// But base64url uses - and _ instead of + and /
// and usually drops the trailing = padding:
atob("-_-_Pj_4")
// Uncaught DOMException: Failed to execute 'atob':
// The string to be decoded is not correctly encoded.
Enter fullscreen mode Exit fullscreen mode

Base64url swaps two characters from the standard alphabet: + becomes -, / becomes _, and trailing = padding is usually dropped. The browser's atob only understands the standard alphabet with correct padding, so it rejects exactly the strings you most often need to decode — JWTs, OAuth state params, anything that travels in a URL.

The fix is a normalization step on every decode:

function decode(str) {
  let n = str.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
  while (n.length % 4 !== 0) n += '=';      // re-add stripped padding
  const raw = atob(n);
  try { return decodeURIComponent(escape(raw)); }  // UTF-8 aware
  catch { return raw; }                            // fall back to raw bytes
}
Enter fullscreen mode Exit fullscreen mode

Tested against the canonical jwt.io token: the header decodes to {"alg":"HS256","typ":"JWT"} and the payload to {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022} — and the same input throws Invalid character through bare atob. That replace/repad dance is the whole reason a dedicated decode beats the raw console call.

The UTF-8 trap, and the emoji that proves it

The second thing naive decoders get wrong is multi-byte text. atob hands you a binary string where each character is one byte. Decode UTF-8 content like café and a naive reader shows you café, because it's reading the two UTF-8 bytes for é as two separate Latin-1 characters.

The decodeURIComponent(escape(raw)) trick handles it: escape percent-encodes each byte, then decodeURIComponent reads those percent groups as UTF-8. Encoding runs the mirror image:

btoa(unescape(encodeURIComponent(data)))
Enter fullscreen mode Exit fullscreen mode

It's an old idiom, but it round-trips correctly, and the try/catch means raw binary that isn't valid UTF-8 falls through untouched instead of corrupting silently. I ran a string of emoji through encode → decode and got byte-identical output the other side.

Where browser-only beats the command line too

I live in a terminal, so I'll be honest about when base64 -d is the right call: scripting, pipes, CI. But three things push me back to a browser tab more often than I expected.

  1. Auto-detect direction. Paste base64, it decodes; paste plain text, it encodes. No flipping a -d flag and re-running.
  2. Per-line mode. A file of base64 strings, one per line, decodes row-by-row instead of being treated as one stream. macOS base64 won't do that without a while read loop.
  3. Image preview. Paste a data:image/png;base64,... URI and render the actual image — the fastest way to sanity-check an inline asset.

And if it's a PWA with a service worker, it works offline: load it once, kill wifi, still decodes — exactly the posture you want for a tool that touches secrets.

The honest limitation

Base64 is encoding, not encryption. Decoding a JWT shows you the claims; it does not verify the signature or let you forge one. If you need to validate signatures or test signing keys, that's a different job — reach for a proper JWT library, not a base64 tool.

If you want a client-side one to poke at, I put the working version of all of the above (base64url normalization, UTF-8 round-trip, per-line, image preview, offline) into a free browser-only tool: Base64Lab. Network tab stays empty by construction. Full write-up with the byte-level details is here.

What's the worst credential you've watched someone paste into a random online tool? I'll start: a live Stripe restricted key, into a "JSON pretty print" site, on a shared screen.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

This is a fantastic reminder. I've often