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=="
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
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
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');
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"}
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-"
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)