UseToolSuite UseToolSuite

Base64 Encoding Mistakes That Silently Break Your App

Common Base64 bugs and how to fix them: Base64URL safety for JWTs, padding inconsistencies, the UTF-8 btoa() crash, and why Base64 is the wrong tool for large file uploads.

Necmeddin Cunedioglu Necmeddin Cunedioglu 6 min read
Part of the Web Security: Encoding and Hashing Guide series

Practice what you learn

Base64 Encoder / Decoder

Try it free →

Base64 Encoding Mistakes That Silently Break Your App

Base64 looks simple: it takes binary data (images, encrypted blobs) and encodes it into a printable ASCII string.

Most developers use it daily — embedding SVG icons in CSS, encoding PEM public keys, transporting JWTs in authorization headers, and serializing binary uploads into JSON payloads.

Because btoa() and atob() are available everywhere, it’s easy to assume Base64 is foolproof. It isn’t. The bugs it causes are insidious: the code works in local testing and then breaks in production when a user with an Arabic name logs in, or when a hashed file happens to produce a + in its encoded output.

This guide covers the main traps: URL-safety failures, padding inconsistencies, UTF-8 crashes, and why Base64 is the wrong tool for large file uploads.

1. How Base64 Works

Before the mistakes, a quick refresher on the mechanics.

Computers store data in 8-bit bytes (256 values). But older protocols (SMTP, legacy URLs) were built to transport only 7-bit ASCII. Send raw binary through an ASCII-only protocol and the bytes get misread as control characters (NULL, Escape), corrupting the file.

Base64 solves this by taking 3 bytes (24 bits) and splitting them into 4 chunks of 6 bits. Each 6-bit chunk maps to an index (0–63) in the Base64 alphabet.

The alphabet:

  • A-Z (indices 0–25)
  • a-z (indices 26–51)
  • 0-9 (indices 52–61)
  • + and / (indices 62 and 63)
  • = (padding)

Because 3 bytes of input become 4 bytes of output, Base64 increases size by about 33%.

Base64 is NOT encryption. It provides no security — it’s a translation from one alphabet to another, like translating English to French. Anyone can decode it. See Why Base64 Is Not Encryption.

2. Mistake #1: Standard Base64 in URLs

The standard Base64 alphabet uses + and /. That’s a silent problem when you put encoded strings in URL parameters (like a password-reset token in an email link).

In URL formatting:

  • + means a space.
  • / means a path separator.
// A backend encodes a JSON object with user roles
const secureToken = btoa('{"role":"admin","id":42}');
// Output: "eyJyb2xlIjoiYWRtaW4iLCJpZCI6NDJ9" (looks fine)

// A slightly longer payload
const differentToken = btoa('{"role":"superuser","id":99}');
// Output: "eyJyb2xlIjoic3VwZXJ1c2VyIiwiaWQiOjk5fQ==" (contains '=' padding)

// A different payload generates '+' and '/'
const fatalUrlToken = btoa('user:admin?role=superuser');
// Output: "dXNlcjphZG1pbj9yb2xlPXN1cGVydXNlcg==" 

The bug: put fatalUrlToken in a GET URL (https://api.example.com/verify?token=dXNlcjphZG1pbj...) and the server parses any + as a space. When the backend decodes the token, it gets the wrong string and throws a 400 Bad Request.

The fix: Base64URL encoding (RFC 4648)

To put Base64 in a URL safely, use the Base64URL variant:

  • + becomes -
  • / becomes _
  • trailing = padding is stripped
// Convert standard Base64 to URL-safe Base64URL
function convertToBase64URL(standardBase64Str) {
    return standardBase64Str
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, ''); // Strip the padding
}

// Convert Base64URL back to standard Base64 before decoding
function convertFromBase64URL(base64urlStr) {
    let standardBase64 = base64urlStr
        .replace(/-/g, '+')
        .replace(/_/g, '/');

    // Re-add the '=' padding
    while (standardBase64.length % 4 !== 0) {
        standardBase64 += '=';
    }
    return standardBase64;
}

This is the encoding that JWTs use.

3. Mistake #2: The UTF-8 Crash

JavaScript’s btoa() (binary to ASCII) is flawed. It dates to the Netscape era and only handles 8-bit Latin-1 characters.

Try to encode a string with UTF-8 characters (emoji, Arabic, Japanese, or even an em-dash) and the browser throws an exception.

// ❌ Crash: Uncaught DOMException: Failed to execute 'btoa' on 'Window'
btoa("Hello 🌍"); 

// ❌ Crash: the string contains characters outside the 8-bit Latin1 range.
btoa("Türkçe Başlık");

The fix: the TextEncoder API

To encode international text, first convert the UTF-16 string into UTF-8 bytes with TextEncoder, then Base64-encode those bytes.

// ✅ Unicode-safe encoding
function encodeUnicodeToBase64(str) {
    // 1. Convert the string to a UTF-8 byte array
    const utf8Bytes = new TextEncoder().encode(str);
    
    // 2. Convert the bytes into a binary string
    const binaryString = Array.from(utf8Bytes, byte => String.fromCharCode(byte)).join('');
    
    // 3. Pass the 8-bit binary string to btoa()
    return btoa(binaryString);
}

// ✅ Unicode-safe decoding
function decodeBase64ToUnicode(base64Str) {
    // 1. Decode Base64 to a binary string
    const binaryString = atob(base64Str);
    
    // 2. Convert it to a Uint8Array
    const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
    
    // 3. Decode the UTF-8 bytes back into a string
    return new TextDecoder().decode(bytes);
}

console.log(encodeUnicodeToBase64("Hello 🌍")); 
// Output: "SGVsbG8g8J+MjQ==" (encoded without crashing)

4. Mistake #3: Double Encoding

This is a subtle logic bug. Because a Base64 string is itself valid ASCII, you can accidentally pass it through btoa() a second time without any error.

// Data arrives from a legacy API already Base64-encoded
const incomingData = "SGVsbG8gV29ybGQ="; // Decodes to "Hello World"

// It gets encoded again before being saved
const storedData = btoa(incomingData);
// Result: "U0dWc2JHOGdWMjl5YkdRPQ==" (now Base64 of Base64)

The bug: a downstream service reads storedData, runs atob() once, and gets SGVsbG8gV29ybGQ=. Assuming it’s fully decoded, it tries to JSON.parse() the string and crashes, because the string is still partly encoded.

The fix: before encoding an arbitrary string, check whether it already looks like Base64.

function looksLikeBase64(str) {
    // 1. Check the string against the Base64 regex
    const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
    if (!base64Regex.test(str)) return false;

    // 2. Verify with a decode/encode round-trip
    try {
        return btoa(atob(str)) === str;
    } catch (err) {
        return false;
    }
}

5. Mistake #4: Base64 for Large File Uploads

Base64 is great for embedding a 2KB SVG icon in a CSS file to avoid an extra HTTP request. It’s a bad choice for large file uploads.

A common anti-pattern: read a large video from <input type="file" />, convert it to a Base64 string with FileReader, and send it to the backend inside a JSON payload.

// ❌ JSON Base64 upload that exhausts memory
const reader = new FileReader();
reader.onload = () => {
    // reader.result is now a huge Data URI string
    fetch('/api/upload/video', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ filename: 'video.mp4', data: reader.result })
    });
};
reader.readAsDataURL(massiveFile);

Why this breaks your infrastructure:

  1. The 33% size penalty: a 100MB video becomes a 133MB Base64 string — 33% more egress bandwidth for no benefit.
  2. Memory exhaustion (OOM): the browser holds the whole 133MB string in RAM to serialize the JSON, and the backend parses the whole payload into RAM. That can trigger out-of-memory crashes on your servers.
  3. No streaming or chunking: you can’t stream a Base64 JSON string efficiently. If the connection drops at 99%, the whole upload corrupts and restarts.

The fix: use multipart/form-data with raw binary streams.

// ✅ Binary multipart upload
const formData = new FormData();
formData.append('file_payload', fileInput.files[0]);

fetch('/api/upload/video', {
    method: 'POST',
    // Don't set Content-Type manually!
    // The browser sets it and adds the multipart boundary.
    body: formData 
});

6. Mistake #5: Padding Inconsistencies

Base64 output length should be a multiple of 4. If the input doesn’t align, the algorithm pads the end with one or two = characters.

The bug is that ecosystems treat padding inconsistently:

  • JavaScript’s atob() in browsers is forgiving — it decodes YWI even though the spec wants YWI=.
  • Strict backend decoders (Java Spring Boot, Go byte parsers) throw a CorruptInputError if the padding is missing.

If your frontend strips padding to save URL space but your backend requires it, authentication fails silently.

The fix: normalize. Before passing a Base64 string to a strict parser, recalculate and re-append the missing padding.

function normalizeBase64Padding(str) {
    // 1. Strip existing padding to find the raw length
    const rawString = str.replace(/=+$/, '');
    // 2. Calculate the missing remainder
    const paddingLength = (4 - (rawString.length % 4)) % 4;
    // 3. Append the right number of equals signs
    return rawString + '='.repeat(paddingLength);
}

Further Reading


Stop guessing why your network payloads fail. Inspect, format, and decode URL-safe Base64 strings with our local Base64 Encoder / Decoder. Verify your API HMAC signatures locally with the Hash Generator.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author
6 min read
-- views

Software developer and the creator of UseToolSuite. I write about the tools and techniques I use daily as a developer — practical guides based on real experience, not theory.