Dev.to · 3 min read

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

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==" 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News