Base64 Encoding Principles: Why 3 Bytes Become 4 Characters & UTF-8 Examples
While inspecting web pages or API traffic, you will frequently encounter long strings that look like data:image/png;base64,iVBORw0KGgo... or three dot-separated segments inside an authentication token. Email attachments, inline CSS icons, and JSON binary fields all use this same format: Base64 encoding. Even experienced developers sometimes run into `InvalidCharacterError` exceptions when passing non-ASCII characters (such as Korean, Japanese, or emojis) into browser Base64 functions. This article explains why Base64 exists, walks step by step through how 3 bytes become 4 ASCII characters using a real UTF-8 example, and covers the most common pitfalls in production JavaScript.
1. What Problem Does Base64 Solve?
At the lowest level, all files and network payloads are sequences of raw 8-bit bytes (`0` to `255`). However, many foundational protocols and document formats—such as SMTP email bodies (MIME), JSON strings, XML documents, and HTML attributes—were designed to transport printable text characters. If you stuff raw binary bytes directly into a text-only channel, byte values corresponding to control characters (`0x00` NUL, `0x0A` LF, `0x0D` CR) or protocol delimiters get stripped, normalized, or corrupted in transit.
Base64 (standardized in RFC 4648) translates arbitrary binary bytes into a safe 64-character alphabet consisting solely of uppercase letters (`A–Z`), lowercase letters (`a–z`), digits (`0–9`), `+`, `/`, and the padding character `=`. Because these 65 printable ASCII characters survive virtually every text parser untouched, binary payloads can travel safely through text-only channels.
Base64 Is Encoding, Not Encryption
Base64 uses a publicly fixed lookup table with no secret key—anyone can decode a Base64 string back to its original bytes in milliseconds. Never treat Base64 as a way to hide passwords, API keys, or personally identifiable information.
Where Base64 Is Commonly Used
- Data URLs (`data:image/...;base64,...`): Embedding small icons or SVGs directly inside HTML or CSS to eliminate separate HTTP round-trips.
- MIME Email Attachments: Encoding PDFs and images so they can be transmitted inside text-based SMTP messages.
- HTTP Basic Authentication (RFC 7617): Encoding `username:password` inside the `Authorization: Basic ...` header (which must always be paired with HTTPS/TLS).
- JSON Web Tokens (JWT): The header and payload segments of a JWT are encoded using Base64URL, a URL-safe variant of Base64.
2. How 3 Bytes (24 Bits) Become 4 Characters
One byte holds 8 bits, whereas each Base64 character represents 6 bits ($2^6 = 64$ possible values). Using the least common multiple of 8 and 6—which is 24 bits—Base64 groups every 3 input bytes (3 × 8 = 24 bits) and slices them into 4 chunks of 6 bits (4 × 6 = 24 bits). Each 6-bit integer (`0` to `63`) is then mapped to its corresponding character in the RFC 4648 alphabet table:
- Input: `[8 bits] [8 bits] [8 bits]` = 24 bits (3 bytes)
- Re-grouped: `[6 bits] [6 bits] [6 bits] [6 bits]` = 24 bits
- Output: 4 Base64 ASCII characters (4 bytes in UTF-8/ASCII)
Because every 3 bytes of input expand into 4 bytes of output, Base64 encoding increases payload size by roughly 33.3% (plus an extra 1–2% if MIME 76-character line breaks are inserted).
3. Step-by-Step Example: Encoding Multibyte UTF-8 ("한글")
Before any text string can be Base64-encoded, it must first be converted into bytes using a character encoding—standardly UTF-8 on the web. In UTF-8, each Korean syllable occupies 3 bytes:
- String → UTF-8 Bytes: The syllable `"한"` (`U+D55C`) encodes to the 3 hex bytes `ED 95 9C`, and `"글"` (`U+AE00`) encodes to `EA B8 80`—a total of 6 bytes.
- Bytes → 24-Bit Stream: Taking the first 3 bytes (`ED 95 9C`) in binary gives `11101101 10010101 10011100`.
- Slice into Four 6-Bit Groups: `111011` (59), `011001` (25), `010110` (22), `011100` (28).
- Map via RFC 4648 Index Table: Indices `0–25` map to `A–Z`, `26–51` to `a–z`, `52–61` to `0–9`, `62` to `+`, and `63` to `/`. Thus `59` → `7`, `25` → `Z`, `22` → `W`, and `28` → `c`, producing `"7ZWc"`.
- Repeating the exact same process on the remaining 3 bytes (`EA B8 80`) yields `"6riA"`, giving the final 8-character Base64 string
7ZWc6riA.
4. Why Padding (`=`) Appears at the End
When the input byte length is not a multiple of 3, the final 24-bit group is incomplete. Base64 pads the trailing bits with zeros to complete the 6-bit boundary and marks the unused output character slots with `=`:
- 1 byte remaining (8 bits): Append 4 zero bits to form 12 bits (2 Base64 characters), then append `==` so the 4-character block is complete. Example: `"A"` (`0x41`) → `QQ==`.
- 2 bytes remaining (16 bits): Append 2 zero bits to form 18 bits (3 Base64 characters), then append a single `=`. Example: `"AB"` → `QUI=`.
5. Handling UTF-8 Safely in JavaScript (`TextEncoder` + `btoa`)
Browser built-ins `window.btoa()` and `window.atob()` were designed around legacy Latin1 ("binary strings") where each character code point must be `0x00–0xFF`. Passing a string containing UTF-16 code units above `255` (such as Korean, emojis, or accented symbols) directly to `btoa("한글")` throws a `DOMException: InvalidCharacterError`. The modern standards-compliant solution recommended by MDN is converting between strings and `Uint8Array` byte buffers via `TextEncoder` and `TextDecoder`:
// String → UTF-8 Uint8Array → Base64
function encodeBase64(text) {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Base64 → UTF-8 Uint8Array → String
function decodeBase64(base64) {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
encodeBase64("한글"); // "7ZWc6riA"
decodeBase64("7ZWc6riA"); // "한글"
6. Common Mistakes to Avoid in Production
- Using deprecated `unescape(encodeURIComponent(str))` hacks: Older StackOverflow snippets rely on deprecated `escape`/`unescape` globals. Always use `TextEncoder` and `TextDecoder` (or `Buffer.from(str, "utf8").toString("base64")` in Node.js).
- Confusing Standard Base64 with Base64URL: In URLs, filenames, and JWT segments, `+` and `/` have special meanings, so Base64URL (RFC 4648 §5) replaces `+` with `-`, replaces `/` with `_`, and strips `=` padding. Passing a raw JWT payload into `atob()` without normalizing `-`/`_` and restoring padding will throw an error.
- Inlining large images as Base64 Data URLs: Because Base64 inflates size by 33% and cannot be cached separately from the HTML/CSS bundle, inlining large photos hurts Core Web Vitals (LCP and FCP). Reserve Data URLs for tiny icons under 2–4 KB.
- Mistaking Base64 for security: Encoding an internal user ID or token in Base64 provides zero confidentiality or integrity protection.
7. Summary
Base64 slices 8-bit byte streams into 6-bit indices mapped to 64 safe ASCII characters, increasing size by ~33% and padding short blocks with `=`. Whenever you encode text in JavaScript, convert strings to UTF-8 bytes first with `TextEncoder` so multibyte characters never break.
To test UTF-8 Base64 encoding and decoding interactively without sending data to a server, try the client-side Base64 Converter in anyTools (open Base64 Codec). To see how Base64 differs from URL percent-encoding and HTML escaping, continue to Differences Between URL Encoding and HTML Entities.