DEV Community

zhihu wu
zhihu wu

Posted on

btoa() Is Lying to You: Base64, Unicode, and the Mojibake in Your JWT

A four-character name broke my base64 encoder last week. Not the network, not the token — the string café. Here is what I found after pulling the whole thing apart.

1. btoa() encodes bytes, and a JS string is not bytes

btoa('café')   // "Y2Fm6Q=="
Enter fullscreen mode Exit fullscreen mode

Looks right. Decode it somewhere that isn't JavaScript:

>>> base64.b64decode('Y2Fm6Q==')
b'caf\xe9'
>>> base64.b64decode('Y2Fm6Q==').decode('utf-8')
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3
Enter fullscreen mode Exit fullscreen mode

café in UTF-8 is five bytes (c a f 0xC3 0xA9), so the correct base64 is Y2Fmw6k=. btoa took the code unit é and emitted the single byte 0xE9 — latin1, not UTF-8. And because atob(btoa('café')) === 'café' is true, a JS-only unit test round-trips perfectly. The corruption only appears on the other side of the wire.

2. Above U+00FF it throws instead

btoa('中文')    // InvalidCharacterError: Invalid character
btoa('👍')      // InvalidCharacterError: Invalid character
btoa('\u00ff')  // "/w=="  ← still fine
btoa('\u0100')  // InvalidCharacterError
Enter fullscreen mode Exit fullscreen mode

The cutoff is exactly 255. Half of your user base is above it. Encode to UTF-8 bytes first:

const toB64 = (s) =>
  btoa(String.fromCharCode(...new TextEncoder().encode(s)));

// Node
Buffer.from(s, 'utf8').toString('base64');
Enter fullscreen mode Exit fullscreen mode

For long strings, don't spread a 100k-element array into fromCharCode — chunk it in 8 KB slices or your call stack will complain.

3. Decoding gives you latin1, not text

atob() returns a "binary string" where every char is one byte. Decode real UTF-8 base64 with it and you get café — the classic mojibake signature of é, å¼, â€". Feed the bytes to TextDecoder('utf-8').

This is where JWTs bite. Take a payload with a Chinese name:

atob(part)  // {"sub":"42","name":"å¼ ä¼","role":"admin"}
Enter fullscreen mode Exit fullscreen mode

JSON.parse succeeds. Nothing throws. You just quietly wrote a corrupted name into your database, your logs, and your audit trail — and it will sit there until someone greps for it.

4. base64url is not base64

JWT parts swap + for -, / for _, and drop the padding:

Buffer.from('a?~').toString('base64')     // "YT9+"
Buffer.from('a?~').toString('base64url')  // "YT9-"
Enter fullscreen mode Exit fullscreen mode

A + in a query string arrives as a space; a / changes how a router splits the path. Meanwhile atob happily tolerates missing padding (atob('eyJhIjoxfQ') works), so client-side code never notices — while a strict decoder on the server raises Incorrect padding. Pad to a multiple of four before you hand a JWT segment to a standard decoder.

The rule I now follow: bytes at the boundary, text only at the edge. If decoded output looks like café or å¼, the token is fine — the decoder guessed latin1. When I need to see both layers, I use this base64 tool because it shows the decoded bytes and the decoded text side by side, which makes a one-byte difference obvious instead of invisible.

Top comments (0)