Common Base64 Mistakes: Invalid Characters, Wrong Padding, Garbled Text and Broken Files

Base64 is simple enough that its bugs rarely look like Base64 bugs. They look like a login that fails, an image that will not render, an accented name that turns into `é`, or a signature that never matches. The encoding almost always did exactly what it was told; the mistake is in what it was given, or in how the output was read. This list is organised by symptom so you can go from what you see to the cause.

"Invalid character" when decoding

The decoder met a character outside the Base64 alphabet. The usual culprits, in order:

Base64url input in a standard decoder: - and _ instead of + and /. JWTs, OAuth PKCE values and many URL tokens use Base64url. Map the characters back (-→+, _→/) or use a decoder with a Base64url option.

A data: URI prefix still attached: data:image/png;base64,iVBOR…. Strip everything up to and including the comma.

Whitespace and line breaks from copying out of an email, a PEM file or a terminal. Most decoders reject them; remove them first.

URL decoding turned + into spaces. A Base64 value passed in a query string without encoding arrives as ab cd instead of ab+cd. Put the plus signs back or, better, encode the value properly when sending it.

// normalise before decoding
const clean = input
  .replace(/^data:[^,]*,/, '')   // data URI prefix
  .replace(/\s+/g, '')          // whitespace, line breaks
  .replace(/ /g, '+')           // + lost to URL decoding (only if you know it happened)
  .replace(/-/g, '+').replace(/_/g, '/');   // Base64url -> Base64

Padding errors

Python raises binascii.Error: Incorrect padding, and other decoders fail similarly, when the length is not a multiple of four. Base64url and JWT parts usually omit =, so add it back: pad with = until len % 4 == 0.

import base64
s = 'eyJhbGciOiJIUzI1NiJ9'                     # length 20: fine
t = 'eyJzdWIiOiIxIn0'                          # length 15: needs one '='
base64.urlsafe_b64decode(t + '=' * (-len(t) % 4))   # b'{"sub":"1"}'

A length that leaves a remainder of 1 when divided by 4 cannot be fixed with padding. No valid encoding has that length, so characters were lost, typically through truncation by a column size limit, a log line cut off, or a copy that missed the last character.

The opposite mistake is also common: comparing a padded and an unpadded value as strings. YQ== and YQ encode the same byte. Decode both and compare bytes, or normalise first.

Decoded text is garbled

Base64 restores bytes exactly. Garbled text means the bytes were turned into text with the wrong character encoding, at encoding or at decoding time.

"café" as UTF-8 bytes     -> Y2Fmw6k=
decoded as UTF-8          -> café      correct
decoded as Latin-1        -> café     classic mojibake

"café" as Latin-1 bytes   -> Y2Fm6Q==  (a different string for the same text)

In browsers the cause is usually btoa() and atob(), which work on Latin-1 "binary strings". btoa('café') happens to work, because é exists in Latin-1, but produces Latin-1 bytes. btoa('😀') throws InvalidCharacterError. Encode text with TextEncoder first, and decode with TextDecoder.

In PowerShell, [Text.Encoding]::Unicode means UTF-16LE, not UTF-8. Base64 of UTF-16 text is twice as long and decodes to text with a NUL after every letter elsewhere. Use [Text.Encoding]::UTF8, unless you are building a -EncodedCommand argument, which does require UTF-16LE.

Decoded files are corrupted

An image that will not open, a PDF reported as damaged, or a ZIP with CRC errors after a Base64 round trip almost always means the bytes went through a text conversion somewhere.

Reading a binary file as text, for example fs.readFileSync(path, 'utf8'), open(path) without 'rb', or file_get_contents followed by a charset conversion, replaces invalid UTF-8 sequences with EF BF BD (U+FFFD) before encoding. The damage is already done. Always read and write binary data as bytes, and write the decoded result with a binary write ('wb', Buffer, Blob).

Check the result by its magic bytes: a PNG starts with 89 50 4E 47, a JPEG with FF D8 FF, a PDF with %PDF, and a ZIP (including DOCX and XLSX) with PK. If the first bytes are right and the file is still broken, compare sizes. A shorter file points to truncation, and a longer one to text conversion.

Line wrapping and the trailing newline

GNU base64 wraps output every 76 characters, and PEM files wrap at 64. Wrapped output inserted into a data URI, a JSON string, an HTTP header or an environment variable breaks it. Use base64 -w0 on Linux, or openssl base64 -A.

echo adds a newline, so echo 'secret' | base64 encodes secret\n. The result, c2VjcmV0Cg==, decodes to a value with a trailing newline, and a password or API key built this way fails authentication. Use printf '%s'. The Cg== or K at the end of the encoded string is the giveaway.

echo 'secret' | base64          # c2VjcmV0Cg==   <- includes \n
printf '%s' 'secret' | base64   # c2VjcmV0

Double encoding and Base64 of the wrong thing

Encoding an already encoded value produces a valid but useless string. The typical sign is that decoding once gives you another Base64 string. It happens when a library encodes binary fields automatically and the caller encodes them again first.

A related mistake is encoding the hex representation of a hash instead of its raw bytes. SHA-256 as hex is 64 characters, and Base64 of that hex text is 88 characters. The expected value, Base64 of the 32 raw bytes, is 44. Subresource Integrity hashes, HTTP Digest headers and many signature schemes expect the latter, and fail with the former.

Treating Base64 as protection

Base64 hides nothing. A password stored as Base64, an API key "obfuscated" with it in a mobile app, or a Kubernetes Secret readable by every developer are all plain text to anyone who looks. Secret scanners and attackers decode Base64 automatically.

Encrypt data that must be confidential, hash passwords with Argon2id or bcrypt, keep secrets in a secret manager, and treat Base64 purely as a transport format.

Sources and further reading

RFC 4648 defines Base64, Base64url and padding. RFC 2045 defines MIME's 76-character lines, and RFC 7468 defines PEM's 64-character lines. MDN documents btoa(), atob(), TextEncoder and the Unicode limitation. The W3C Subresource Integrity specification defines Base64-encoded digests.

Frequently asked questions

Why does my Base64 decode fail with an invalid character?

The input contains characters outside the Base64 alphabet: usually Base64url `-` and `_`, a `data:` prefix, whitespace or line breaks, or spaces where `+` signs were lost to URL decoding. Clean or convert the input before decoding.

How do I fix Base64 incorrect padding?

Add `=` until the length is a multiple of 4, which is common for Base64url and JWT parts. If the length modulo 4 is 1, the string was truncated and cannot be repaired by padding.

Why does btoa throw InvalidCharacterError?

`btoa('😀')` fails because the emoji lies outside Latin-1, the only range `btoa()` accepts. `btoa('café')` does not throw, but it encodes Latin-1 bytes (`Y2Fm6Q==`), which a UTF-8 system decodes as garbage. Both have the same fix: turn the text into UTF-8 bytes with `TextEncoder` before encoding.

Why is my decoded image or PDF corrupted?

The binary data was treated as text at some point, which replaces invalid byte sequences. Read files as bytes before encoding, decode to bytes, and write them with a binary write.

Why does my Base64 string end in Cg==?

`Cg==` is the encoding of a trailing newline, usually added by `echo`. Use `printf '%s'` or `echo -n` so the newline is not encoded.

Is Base64 secure for passwords or API keys?

No. It is reversible by anyone and offers no protection. Use proper encryption for confidential data and a password hashing algorithm such as Argon2id for passwords.