Base64 turns any bytes into a string of 64 safe, printable characters, so binary data can travel through systems built for text. It works by taking 3 bytes (24 bits) and rewriting them as 4 characters of 6 bits each. That 4-for-3 swap is the whole reason the output is a third larger. It offers no secrecy at all: anyone can reverse it with no key.
Why it exists
Older protocols such as email were designed for 7-bit text, and some treat certain bytes as control signals. A raw JPEG sent through them would be corrupted. Today the same problem shows up when you need to put binary data inside something that is text: a JSON string, a URL, an HTML attribute, an HTTP header. Base64 gets through because its output uses only letters, digits and a couple of symbols.
Worked example: encoding “Man”
The alphabet in RFC 4648 assigns one character to each 6-bit value:
| Values | Characters |
|---|---|
| 0 to 25 | A to Z |
| 26 to 51 | a to z |
| 52 to 61 | 0 to 9 |
| 62 | + |
| 63 | / |
Step 1: get the bytes. In ASCII, M is 77, a is 97 and n is 110:
M a n
01001101 01100001 01101110
Step 2: join the bits and cut into groups of six.
010011 010110 000101 101110
Step 3: read each group as a number, and look it up.
| Bits | Value | Character |
|---|---|---|
010011 | 19 | T |
010110 | 22 | W |
000101 | 5 | F |
101110 | 46 | u |
So Man encodes to TWFu. You can check it in a terminal:
printf 'Man' | base64 # TWFu
printf 'TWFu' | base64 -d # Man
Padding: when the bytes do not divide by three
If the input isn’t a multiple of 3 bytes, the last group comes up short. Encoding only M, you have 8 bits, which makes one full 6-bit group and 2 left over. Fill with zeros to make a second group:
010011 01[0000] → 19, 16 → T, Q
A valid unit must have four characters, so = fills the gap. The result is TQ==. Likewise, Ma encodes to TWE=.
Those = signs tell a decoder how many bytes the last group holds: == means one, = means two. Padding carries no data, so some formats omit it, because the length can be worked out from the string.
Where the 33 percent comes from
Every 3 input bytes become 4 output characters, which gives a ratio of 4/3, or 1.333. Small inputs do worse, since padding rounds up to a whole group. We measured these lengths in Node.js:
| Input bytes | Base64 characters | Ratio |
|---|---|---|
| 1 | 4 | 4.000 |
| 2 | 4 | 2.000 |
| 3 | 4 | 1.333 |
| 4 | 8 | 2.000 |
| 100 | 136 | 1.360 |
| 1,000 | 1,336 | 1.336 |
| 1,048,576 (1 MiB) | 1,398,104 | 1.333 |
The formula for padded output is 4 × ceil(n / 3).
MIME email adds more. RFC 2045 limits encoded lines to 76 characters, each followed by CRLF, so the real cost is about 37 percent. That’s why a 10 MB attachment can hit a provider’s size limit sooner than you’d expect.
The URL-safe variant
Two characters in the standard alphabet cause trouble in URLs: + can be read as a space in a query string, and / is a path separator. The = sign has meaning in query strings too. RFC 4648 section 5 defines “base64url”, which swaps two characters and usually drops the padding:
| Standard | URL-safe |
|---|---|
+ | - |
/ | _ |
= padding | often omitted |
Here are the bytes FB FF FE, encoded both ways in our test:
Standard: +//+
URL-safe: -__-
And the text hi?>:
Standard: aGk/Pg==
URL-safe: aGk_Pg
JSON Web Tokens use base64url with no padding, as you can see in how JWTs work. The two forms aren’t interchangeable. In Node.js, atob("aGk_Pg") throws InvalidCharacterError. To decode URL-safe text with a standard decoder, swap - for +, swap _ for /, and add = until the length divides by 4.
The Unicode trap in JavaScript
Base64 encodes bytes, not characters, so text must first become bytes, and the encoding you pick matters. The browser function btoa accepts only characters up to U+00FF and treats each as one byte. We ran these:
btoa("café") // "Y2Fm6Q==" (Latin-1 bytes, not UTF-8)
btoa("日本") // throws InvalidCharacterError
The UTF-8 encoding of café is Y2Fmw6k=, which is different. Encode with one and decode with the other, and you get garbage. A safe browser version uses TextEncoder:
function encodeUtf8(text) {
const bytes = new TextEncoder().encode(text);
return btoa(String.fromCharCode(...bytes));
}
encodeUtf8("日本"); // "5pel5pys"
That spread works for short strings. For large inputs, loop over the bytes, because a huge spread can exceed the argument limit.
Our Base64 tool does this conversion for you: it encodes text as UTF-8 first, and 日本 round-trips correctly. Toggles cover the variants in this article: the URL-safe alphabet, leaving off padding, and wrapping at 76 characters for MIME. When decoding, it accepts either alphabet with or without padding, so you don’t need the character swap above. It can also encode an uploaded file. For pictures you want to embed in a page, Image to Base64 is the more direct route.
Base64 is not encryption
This deserves plain words, because mistaking one for the other causes real incidents. Encryption requires a key and can’t be reversed without it. Base64 has no key. It is a public, fixed mapping, like writing a word in Morse code.
HTTP Basic authentication shows the risk. It sends Authorization: Basic YWRtaW46czNjcmV0, which looks scrambled. Decode it and you get admin:s3cret. That’s why Basic auth is safe only over HTTPS.
The same mistake appears elsewhere:
- Kubernetes Secrets store values Base64 encoded, which is a format choice. Anyone who can read the object can read the value, so real protection has to come from access control and encryption at rest.
- API keys or passwords “obfuscated” in config files or client-side code. A person who spots trailing
=signs will try decoding within seconds. - Base64 of a JWT payload, which is why tokens are readable by their holder.
To protect data, encrypt it with something like AES-GCM. To fingerprint it, use a hash. The differences are in hashing versus encryption.
When to use it, and when not to
Good uses: embedding a small binary in JSON or XML, email attachments, inline data: URIs for a tiny icon, and carrying keys or signatures in text formats such as PEM.
Poor uses:
- Large images in HTML or CSS. You add 33 percent, and the data can’t be cached separately or loaded lazily. Inline only very small assets, and serve anything else as a file.
- Large file uploads inside JSON. You pay the size cost plus extra memory to hold and parse a long string. Use a multipart upload or a direct upload to storage.
- Storing binary in a database as text. Most databases have binary column types, which avoid the overhead altogether.
Compression can recover some of the cost, since gzip works well on Base64 of plain text. Compress before you encode, however, as encoded data compresses less well, and already-compressed formats such as JPEG will gain nothing in either order.