Last year a client's API kept returning garbage for a customer_name field I was sending as base64. Every Latin name came back perfect. Every Chinese name came back as 5L2g5aW9 that decoded to mojibake. I spent an afternoon tracking it down, and the culprit was a one-line function I'd copy-pasted from a blog post.
If you just need an answer, base64 handles UTF-8 correctly out of the box on Linux and macOS:
echo -n "你好 base64" | base64
# 5L2g5aW9IGJhc2U2NA==
The -n flag matters — without it you encode a trailing newline and the output changes. To decode back: echo "5L2g5aW9IGJhc2U2NA==" | base64 -d. Same result, every time.
In the browser, the tempting shortcut is btoa(). It's native, it's one call, it's wrong:
// ❌ breaks on any character above U+00FF
btoa("你好"); // throws InvalidCharacterError
btoa() only understands Latin-1. A character like 你 (U+4F60) is outside that range, so it throws — or silently corrupts input if you've pre-mangled it. The fix is to encode the string to UTF-8 bytes first, then base64 those bytes:
function utf8ToBase64(str){
const bytes = new TextEncoder().encode(str); // UTF-8 bytes
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin); // now safe
}
utf8ToBase64("你好"); // "5L2g5aW9" ✅
I put exactly this function on the homepage tool so I stop copy-pasting it from my notes.
Three checks I run before trusting any encoder:
1. Round-trip. Decode what you just encoded. If you don't get the original string back, the encoder is broken.
2. Test a non-Latin string. Encode 你好 — if it throws or changes, the tool isn't UTF-8 safe.
3. Check the padding. Valid base64 length is a multiple of 4; strings end in = or ==.
If you're encoding text that goes into a JSON payload, also remember that base64 can contain + and /, which some JSON parsers and URLs mangle. That's when you reach for base64url — but that's a post for another week.
The spec for the padding and alphabet is RFC 4648, which I finally read after that client call.