Published Updated
Base64 re-expresses arbitrary bytes in a 64-character alphabet so they survive systems that were built to carry text. It is one of the few encodings a working engineer meets every week — in JWTs, Data URLs, HTTP Basic credentials, email attachments, TLS certificates — and one of the most consistently misread. It is a transport format. It is not a secret, not a compression scheme, and not a character encoding.
The problem Base64 actually solves
A surprising number of channels in a typical stack promise to carry text and only text. An HTTP header value, a JSON string, an XML attribute, an SMTP message body, a CSS url() reference, a shell environment variable: each has characters that are structural, characters that are forbidden outright, and — in the older protocols — an inherited assumption that only the low seven bits of every byte are meaningful. Push raw binary through any of them and something along the path will reinterpret a byte, swallow a control character, or rewrite a line ending.
Base64 sidesteps all of that by mapping every three bytes of input onto four characters drawn from a set that every ASCII-compatible system agrees on: A to Z, a to z, 0 to 9, and two symbols. The result is longer than the input, but it passes through text-only plumbing unchanged and decodes back to the original bytes exactly, with no ambiguity about which byte was which.
That framing is worth stating precisely, because it marks the boundary of the tool. Reach for Base64 when a byte sequence has to cross something that only promises to carry text. Do not reach for it because the output looks unreadable afterwards. Illegibility is a side effect of the alphabet, not a property you can rely on.
How the transform works
Base64 ignores byte boundaries and treats the input as a stream of bits. It takes 24 bits at a time — three bytes — and re-slices them into four groups of six. Six bits hold a value from 0 to 63, which indexes directly into the alphabet. That is the whole algorithm; everything else is handling the case where the input length is not a multiple of three.
Working one example by hand is the fastest way to stop guessing. Take the three-byte string Man. Its bytes are 0x4D, 0x61, 0x6E. Concatenate the bits, cut them into sixes, and look each value up in the alphabet.
Because the alphabet has 64 entries and each output character carries exactly six bits, the growth factor is fixed: four characters for every three bytes, or four times the ceiling of the byte count divided by three. There is no input for which Base64 gets smaller, and no content type for which it compresses. Encoding an already-compressed file makes it bigger, which is why gzip belongs before Base64 in a pipeline, never after.
Input bytes M = 0x4D a = 0x61 n = 0x6E
Bit stream 0 1 0 0 1 1 0 1 0 1 1 0 0 0 0 1 0 1 1 0 1 1 1 0
Regrouped 0 1 0 0 1 1 | 0 1 0 1 1 0 | 0 0 0 1 0 1 | 1 0 1 1 1 0
Values 19 22 5 46
Alphabet T W F u
Result Man -> TWFu
Alphabet index: 0-25 = A-Z 26-51 = a-z 52-61 = 0-9 62 = + 63 = /Base64 versus Base64URL
The two symbols at indexes 62 and 63 are the only place the common variants disagree, and they disagree because plus and slash are both meaningful inside a URL. A slash separates path segments; a plus is decoded as a space by anything that reads application/x-www-form-urlencoded. A standard Base64 value dropped into a query string will therefore sometimes survive and sometimes corrupt, depending entirely on which bytes it happened to contain.
Base64URL, defined alongside standard Base64 in RFC 4648, swaps those two characters for hyphen and underscore, both of which are unreserved in a URL and need no further escaping. It also conventionally omits the trailing padding, because an equals sign has its own meaning in a query string. JWTs, WebAuthn payloads, and most modern token formats use this variant.
The variants are not interchangeable, and the failure is quiet. Decoding a Base64URL value with a strict standard decoder raises an invalid-character error on the first hyphen; decoding a standard value with a strict URL-safe decoder does the same on the first plus. Many libraries are lenient and accept both, which is convenient until the one service in the chain that is strict rejects a token that worked everywhere else.
Both variants encode identical bytes. Only the last two alphabet entries and the padding convention differ.
| Property | Standard (RFC 4648 §4) | URL-safe (RFC 4648 §5) |
|---|---|---|
| Index 62 | + | - |
| Index 63 | / | _ |
| Padding | = required by the spec | = usually omitted |
| Safe in a URL path or query | No | Yes |
| Bytes 0xFB 0xFF encode to | +/8= | -_8 |
| Typical home | MIME, PEM, Data URLs, Basic auth | JWT, WebAuthn, OAuth PKCE, URL slugs |
Padding, and why decoders argue about it
When the input length is not a multiple of three, the final group has only 8 or 16 bits instead of 24. The encoder zero-fills to the next six-bit boundary, emits the two or three characters that carry real data, and appends equals signs so that the output length is always a multiple of four. One leftover byte produces two characters and two equals signs; two leftover bytes produce three characters and one equals sign.
The padding carries no information. The length of the output already tells a decoder how many bytes the last group holds, which is exactly why Base64URL can drop it. What padding buys you is the ability to concatenate encoded values and still find the boundaries, which matters for MIME and PEM and matters not at all for a token in a URL.
In practice, the arguments come from strictness mismatches rather than from the rule itself. Python's base64.b64decode raises a binascii error on missing padding unless you add it back; JavaScript's atob accepts unpadded input in modern engines; Java's Base64.getUrlDecoder accepts both. The portable habit is to encode exactly what the receiver documents and, when decoding an untrusted value, re-pad to a multiple of four before handing it to a strict library.
"Man" (3 bytes, no remainder) -> TWFu no padding
"Ma" (2 bytes, 1 short) -> TWE= one "="
"M" (1 byte, 2 short) -> TQ== two "="
Unpadded Base64URL of the same inputs: TWFu / TWE / TQ
Re-padding before a strict decode:
const padded = value + "=".repeat((4 - (value.length % 4)) % 4);Text, UTF-8, and the step people skip
Base64 encodes bytes. A string is not bytes until you choose a character encoding, and that choice happens before Base64 ever runs. Almost every Base64 defect involving non-ASCII text is really a character-encoding defect that Base64 faithfully preserved and then got blamed for.
Choose UTF-8 and the round trip is clean. The three-byte UTF-8 representation of 中 is 0xE4 0xB8 0xAD, which encodes to 5Lit, and decoding 5Lit as UTF-8 gives 中 back. Choose a legacy encoding on one side and UTF-8 on the other and the bytes still survive Base64 perfectly — they simply decode into different characters, which is why the corruption appears at the far end and looks like the encoder's fault.
The classic browser version of this is atob and btoa. Both operate on Latin-1 code units, so btoa throws an InvalidCharacterError the moment a string contains a code point above U+00FF. The fix is not a cleverer Base64 routine; it is to serialise the string to UTF-8 bytes first with TextEncoder, then Base64-encode those bytes. Server-side languages mostly make the encoding explicit in the API, which is why this failure is so much more common in front-end code.
btoa("中"); // InvalidCharacterError
const bytes = new TextEncoder().encode("中"); // Uint8Array [228, 184, 173]
btoa(String.fromCharCode(...bytes)); // "5Lit"
new TextDecoder().decode(
Uint8Array.from(atob("5Lit"), (c) => c.charCodeAt(0))
); // "中"
Reference values, UTF-8 then Base64:
"hello" 68 65 6C 6C 6F -> aGVsbG8=
"café" 63 61 66 C3 A9 -> Y2Fmw6k=
"中" E4 B8 AD -> 5Lit
"中文" E4 B8 AD E6 96 87 -> 5Lit5paHData URLs, images, and the cost of inlining
A Data URL packages a media type and a payload into a single URI, so an image can live inside an HTML attribute or a CSS rule with no second request. The shape is data:, the MIME type, the literal ;base64, and then the encoded bytes. A PNG always begins with the same eight-byte signature, which is why every Base64-encoded PNG on the web starts with the characters iVBORw0KGgo — a useful sanity check when you are staring at a payload and cannot tell what it is.
The cost is the reason to be deliberate about it. Four output characters per three input bytes is a 33 percent increase before you count the prefix, and inlined bytes cannot be cached separately, cannot be served by a CDN on their own, and are re-downloaded with every copy of the containing document. They also bloat the HTML or CSS that a browser must parse before it can render anything.
That trade-off is worth taking for small, stable, render-blocking assets: an icon in a critical stylesheet, a placeholder in an email template, a logo in a self-contained report. It is a poor trade for photographs, for anything a user might change, and for assets shared across pages. If an image is large enough that you are wondering whether to inline it, the answer is almost certainly no.
Output length is always four times the ceiling of the byte count divided by three, before the Data URL prefix is added.
| Input size | Base64 characters | Effective growth |
|---|---|---|
| 1 byte | 4 | 4.00x |
| 3 bytes | 4 | 1.33x |
| 100 bytes | 136 | 1.36x |
| 1 KiB (1024 bytes) | 1368 | 1.34x |
| 100 KiB (102400 bytes) | 136536 | 1.33x |
Base64 is not encryption, and everyone knows it
Decoding Base64 requires no key, no secret, and no permission. Every language ships a decoder, every browser has one built into the console, and the alphabet is recognisable on sight. A trailing equals sign, a string of mixed-case letters with no spaces, a prefix like eyJ that decodes to an opening brace and a quote — these are read at a glance by anyone who spends time near an API.
This matters because Base64 shows up in places that look protective and are not. HTTP Basic authentication sends Base64 of user:password, so Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== is a plaintext credential wearing a costume; it is only safe because TLS is underneath it. A JWT's header and payload are Base64URL-encoded JSON that any holder can read — the signature proves who issued the token, not that the contents are private. Configuration values Base64-encoded to keep them out of a log are still in the log.
The correct reading is that Base64 answers a transport question and says nothing about confidentiality. If a value must stay private, encrypt it or keep it in a secret manager, and then, if it also has to travel through a text-only channel, Base64 the ciphertext. Getting the order right is the whole point.
- Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== decodes to the string Aladdin:open sesame.
- A JWT segment beginning eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 is the JSON header {"alg":"HS256","typ":"JWT"}, readable by anyone.
- A Data URL that starts /9j/ is a JPEG; one that starts iVBORw0KGgo is a PNG; one that starts R0lGODlh is a GIF.
What to remember
- Use Base64 only to move bytes through a channel that promises text, and expect the payload to grow by roughly a third.
- Match the variant to the destination: standard Base64 with plus and slash for MIME and Data URLs, Base64URL with hyphen and underscore for anything that goes in a URL or a token.
- Encode text to UTF-8 bytes before Base64 and decode in the same order, especially in browser code where btoa will throw on any character above U+00FF.
- Re-pad an untrusted Base64 value to a multiple of four before handing it to a strict decoder, since padding carries no information but some libraries insist on it.
- Never treat a Base64 value as hidden — decode it yourself with the Base64 Text tool before deciding whether it is safe to log, share, or commit.