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.

Published 2026-09-24

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 unescapeTextEncoder 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 paddingTWFu vs TWFu. JWTs and many URL tokens omit = by spec; Go’s StdEncoding then errors (RawStdEncoding is the no-pad variant). atob tolerates missing padding entirely: atob("TQ")M. Fix: append = until length % 4 == 0.
  • Miscounted paddingTQ= (one = where two are needed) fails in atob with InvalidCharacterError, though lenient decoders accept it. This site tolerates it and reports the repair.
  • Padding mid-stringTW=u or = 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 is 00, 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 ;base64 is percent-encoded, not base64 — feeding hello%20world to 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

  1. Paste into the decoder — the error points at the exact character.
  2. Read the status notes: it tells you which repairs it made (whitespace, padding, URL-safe alphabet, data-URL prefix) — that’s usually the answer.
  3. Decode → Hex to see the real first bytes: FF D8 JPEG, 89 50 4E 47 PNG, 25 50 44 46 PDF, FF FE UTF-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.