BriskFile

Guides ·

Why base64 tools break on café, 你好 and emoji

The btoa trap, why "InvalidCharacterError" is the good outcome, and how to encode text of any language to base64 correctly in JavaScript.

Because btoa takes a binary string — one character per byte — and throws on any character above U+00FF. Text has to be encoded to UTF-8 bytes first, with TextEncoder, and base64 applied to those bytes.

The error, and why it is the good outcome

btoa('café')
// InvalidCharacterError: The string to be encoded contains characters
// outside of the Latin1 range.

That exception is the most useful thing btoa does. It is telling you that the function you reached for does not do what you assumed, and it is stopping before it produces something wrong.

btoa is not a text encoder. It converts a binary string — a string in which every character code is a single byte, 0 to 255 — into base64. Feed it a character above U+00FF and there is no byte for it to use, so it refuses.

The trouble is what people do next.

The three ways this gets “fixed”, two of which are wrong

ApproachResult on caféVerdict
btoa(str)ThrowsHonest failure
btoa(str.replace(/[^\x00-\xFF]/g, ''))cafY2FmSilent data loss
btoa(unescape(encodeURIComponent(str)))Y2Fmw6k=Correct, via two deprecated functions
btoa(String.fromCharCode(...new TextEncoder().encode(str)))Y2Fmw6k=Correct, breaks on long input
Encode the bytes directlyY2Fmw6k=Correct

The second row is the dangerous one, and it is what a great many online converters do. Stripping the offending characters turns a loud failure into a silent one: you get a valid-looking base64 string that decodes to different text, and nothing anywhere tells you a character went missing. A name loses its accent. A Chinese sentence becomes empty. The string round-trips through your system looking perfectly healthy.

The fourth row is correct but has a sharp edge: String.fromCharCode(...bytes) spreads every byte as a function argument, and browsers have an argument-count limit somewhere around 100,000. It works in testing and throws on a large file.

What is actually going on

Base64 encodes bytes, not characters. So the first question is always: which bytes?

“café” is four characters. As UTF-8 it is five bytes, because é is encoded as two (0xC3 0xA9). As UTF-16 it would be eight. Base64 has no opinion about which encoding you meant — it just needs bytes, and you have to decide what they are before you start.

The convention on the web, and what every receiving system will assume, is UTF-8. So the correct sequence is:

  1. Text → UTF-8 bytes (TextEncoder)
  2. Bytes → base64

And in reverse:

  1. Base64 → bytes
  2. Bytes → text (TextDecoder, and use { fatal: true })
const toBase64 = (text) => {
  const bytes = new TextEncoder().encode(text);
  let binary = '';
  // A loop, not a spread: the argument-count limit makes the one-liner
  // fail on large inputs, and it fails only once the input gets big.
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
};

const fromBase64 = (encoded) => {
  const binary = atob(encoded);
  const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
  // fatal: true, so invalid bytes throw rather than becoming U+FFFD.
  return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
};

toBase64('café');   // 'Y2Fmw6k='
toBase64('你好');    // '5L2g5aW9'
toBase64('👍');     // '8J+RjQ=='

Verify any implementation against a different one rather than against its own decoder. In Node:

Buffer.from('café', 'utf8').toString('base64');  // 'Y2Fmw6k='

A round-trip through your own encoder and decoder proves only that the two halves agree — which they will, even if both are wrong in the same way.

Standard base64 or base64url

There are two alphabets, and the difference is two characters plus the padding.

Characters 62 and 63PaddingUsed by
Standard (RFC 4648 §4)+ and /=Email, data URIs, most APIs
URL-safe (RFC 4648 §5)- and _Usually droppedJWTs, URLs, filenames

+ means a space in a query string and / is a path separator, so the standard alphabet cannot go into a URL untouched. The URL-safe variant swaps those two characters and drops the = padding, because = would itself need percent-encoding.

This is why a JWT has no equals signs in it: each of its three segments is base64url. Decoding is more forgiving than encoding — you can accept either alphabet and missing padding without being told which you were given.

Base64 is not encryption

Worth stating plainly, because it causes real incidents. Base64 is a public, reversible, key-less transformation. Anyone who sees the string can read the original instantly. Storing a password base64-encoded protects nothing whatsoever.

The confusion is understandable: encoded text is unreadable to a person, and unreadable feels like safe. It is not the same property. If it must be unreadable to whoever holds it, encrypt it. If it must not be recoverable at all, hash it. Base64 is for transport, and only for transport.

Size

Three bytes become four characters, so base64 is about 33% larger than the data it carries, plus padding and any line breaks.

That is inherent and cannot be optimised away. It is why a data: URI suits a 400-byte icon and not a photograph — an inlined image cannot be cached separately, cannot be lazy-loaded, blocks the stylesheet or document it sits inside, and arrives a third bigger than the file it came from. The rough line is a couple of kilobytes.

If you just want the answer

The base64 tool here encodes through UTF-8, so accents, Chinese and emoji all work; reads either alphabet with or without padding; strips a data: prefix if you paste a whole URI; and, when the bytes are not valid UTF-8, tells you it decoded to binary and offers the file rather than handing back a row of replacement characters. It runs in your browser, which matters more than it sounds — the strings people paste into a base64 decoder are usually tokens.