Common Base64 Bugs: the btoa Unicode Trap, Line Wraps & Padding
The base64 bugs that actually bite: btoa throwing on Unicode, MIME's 76-column wrap, missing/miscounted padding, alphabet mixups and truncation.
Base64 is a 30-line algorithm, yet it generates a steady stream of production bugs — because the failures happen at the boundaries: charsets, transports, and variant alphabets. These are the seven that burn real debugging time, each with the broken and fixed shape.
1. The btoa Unicode trap
btoa("München"); // works — ü is U+00FC, under the U+00FF ceiling
btoa("Straße"); // works too — ß is U+00DF
btoa almost works — which is why code like this ships to production and then explodes on the first emoji:
btoa("✓"); // InvalidCharacterError — U+2713 > U+00FF
btoa("中"); // InvalidCharacterError — U+4E2D
btoa("🚀"); // InvalidCharacterError — every emoji
btoa takes a binary string — one byte per character — and throws on anything above U+00FF. The correct path is UTF-8 bytes first:
// encode: text → UTF-8 bytes → binary string → btoa
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode("中 🚀")));
// "5LitIPCfmoA=" (correct)
// decode: atob → binary string → bytes → UTF-8 text
const text = new TextDecoder().decode(
Uint8Array.from(atob("5LitIPCfmoA="), c => c.charCodeAt(0)));
The popular shortcut btoa(unescape(encodeURIComponent(s))) produces the same bytes but relies on the deprecated unescape — TextEncoder is the modern, spec’d route. This site does the byte path natively, so 👋 ↔ 8J+Riw== round-trips.
2. The 76-column wrap (MIME) and the 64-column wrap (PEM)
MIME’s base64 (RFC 2045) wraps output at 76 characters per line with CRLF; PEM (certificates, keys) wraps at 64. Paste either into a decoder that treats \n as data and it fails — or worse, a decoder that ignores unknown characters silently produces the right bytes except where the armor lines (-----BEGIN CERTIFICATE-----) contribute garbage bytes.
broken: "U2FsdGVkX1+9W8rK\nZGVjYXRlZCBjb250ZW50" → strict decoder: error at char 17
fixed: strip whitespace first (RFC 2045 actually *requires* decoders to ignore
non-alphabet characters), and cut PEM armor lines before decoding
This site’s decoder strips all ASCII whitespace and reports it did so — wrapped input just works.
3. Padding: missing, miscounted, misplaced
The = rules bite in three distinct ways:
- Missing padding —
TWFuvsTWFu. JWTs and many URL tokens omit=by spec; Go’sStdEncodingthen errors (RawStdEncodingis the no-pad variant).atobtolerates missing padding entirely:atob("TQ")→M. Fix: append=until length % 4 == 0. - Miscounted padding —
TQ=(one=where two are needed) fails inatobwithInvalidCharacterError, though lenient decoders accept it. This site tolerates it and reports the repair. - Padding mid-string —
TW=uor=anywhere but the last two positions is always corrupt; a decoder that ignores it produces wrong bytes silently. That’s a hard error here, with the position shown.
4. Alphabet mixups: +/ vs -_
Standard base64 and base64url differ in exactly two characters — which means a base64url string passed to a standard decoder either errors (atob("----") → InvalidCharacterError) or, in a decoder that silently drops unknown characters, decodes to wrong bytes without complaint. The failure is invisible precisely where the alphabet mattered. If decoded output is plausible-but-wrong, check for -/_ vs +// first. Base64 vs base64url has the full story.
5. Truncation and the impossible length
A base64 data length ≡ 1 (mod 4) cannot exist — one character is 6 bits, less than a byte — so TWFrZ (5 data chars) is truncated input, guaranteed. More insidious: lengths ≡ 2 or 3 (mod 4) decode fine to a prefix of the real data — a truncated token can produce a parseable-but-short result. When decoded output is mysteriously cut short, count the input length mod 4 before suspecting the decoder. This site’s decoder calls out ≡ 1 immediately and tolerates the rest.
6. Double encoding
SGVsbG8= is “Hello” — and U0dWc2JHOD0= is… also valid base64, which decodes to the string SGVsbG8=. Somewhere upstream, already-encoded data got encoded again (a classic with env vars and JSON fields). Signature: the “decoded” output is itself alphabet-shaped and length-divisible-by-4. Fix is decode-twice — or hit ⇄ Swap and decode again.
7. Base64 is bytes, not text
Decoding produces bytes; calling them “a string” is where mojibake is born:
- UTF-16 content (common in Windows/.NET pipelines —
Encoding.Unicode) decoded as UTF-8 → every other byte is00, shown as�or invisible gaps. The UTF-16 BOM (FF FE/FE FF) is visible in Hex view. - Latin-1/Windows-1252 bytes decoded as UTF-8 → accented characters become
�. - A
data:URL without;base64is percent-encoded, not base64 — feedinghello%20worldto a base64 decoder errors on%.
The Show as control exists for exactly this: auto shows text only for valid UTF-8; Hex bytes shows what you actually got; Text forces the UTF-8 interpretation (invalid sequences → �).
A two-minute triage
- Paste into the decoder — the error points at the exact character.
- Read the status notes: it tells you which repairs it made (whitespace, padding, URL-safe alphabet, data-URL prefix) — that’s usually the answer.
- Decode → Hex to see the real first bytes:
FF D8JPEG,89 50 4E 47PNG,25 50 44 46PDF,FF FEUTF-16 — the bytes rarely lie.
Frequently asked questions
Why does btoa('é') throw but btoa('e') doesn't?
btoa accepts a binary string: every character must be a code point ≤ U+00FF so it maps to exactly one byte. 'é' is U+00E9 — fine. '✓' is U+2713, '中' is U+4E2D, every emoji is way past U+00FF — all throw InvalidCharacterError. The fix is encoding to UTF-8 bytes first (TextEncoder), which is what this site does internally.
atob() keeps throwing on base64 that looks fine — why?
The usual suspects, in order: whitespace or newlines are fine (atob strips ASCII whitespace) — but URL-safe characters (-, _) throw, wrong padding count throws (atob('TQ=') fails even though atob('TQ') and atob('TQ==') both work), and any character outside the alphabet throws. Log which of the three it is before rewriting your pipeline.
My decoder works but outputs garbage — most likely cause?
The decode succeeded; the interpretation is wrong. Base64 gives you bytes, and bytes become text only through a charset. UTF-16 or Latin-1 content decoded as UTF-8 produces mojibake or � — check for the UTF-16 BOM (FF FE / FE FF, visible in hex view) or force the Text view and look at the replacement characters.
The same string decodes to different bytes in two tools — who's right?
Compare the inputs character-by-character, not the outputs: a - vs + or _ vs / swap is one alphabet difference; if both tools decode silently to different bytes, one of them is substituting unknown characters instead of erroring. Decode with an exact-position error report (this site shows the offending character) to see what each tool was really fed.
Is there a reliable way to check if a string is base64 at all?
Only the round-trip: decode it, re-encode the bytes, compare. Length divisible by 4 and alphabet membership are necessary but not sufficient — deadbeef is simultaneously valid hex and valid base64 (it decodes to 6 bytes of binary). Any 'is this base64?' heuristic that doesn't round-trip will false-positive on ordinary words.