Base64 is not encryption
A while back a teammate sent over a config file with the database password "secured" by Base64. He had pasted it into an online encoder and was convinced it was unreadable. I decoded it in the browser console before he finished explaining. That is the entire failure mode in one minute: Base64 changes how data looks, not who can read it.
The mistake is easy to make because Base64 output looks like noise, and noise reads as "encrypted" to anyone who has not been burned by it yet. It is not cosmetic confusion either. Credentials encoded this way end up in logs, issue trackers and repositories, where anyone with a decoder can read them.
Three concepts, three different jobs
Encoding, encryption and hashing get used interchangeably in conversation, but they answer different questions:
| Question it answers | Reversible? | Needs a key? | |
|---|---|---|---|
| Encoding (Base64) | How do I move these bytes through a text-only channel? | Yes, trivially | No |
| Encryption (AES-GCM) | How do I keep others from reading this? | Only with the key | Yes |
| Hashing (SHA-256, bcrypt) | Has this changed, or does this input match a stored digest? | No | No, apart from salts |
Only the middle row provides confidentiality. Base64 sits in the first row, and its reversibility is not a weakness, it is the point.
What Base64 actually does
Base64 takes every 3 bytes of input and maps them onto 4 printable characters from a 64-symbol alphabet: A-Z, a-z, 0-9, plus + and /. Three bytes is 24 bits; four characters at 6 bits each is exactly 24 bits. That is why the output length is always a multiple of four, and why the payload grows by about 33% (4/3).
When the input length is not a multiple of three, the encoder pads the tail with = or ==. Those equals signs carry no data. They only tell the decoder how many bytes were in the final group.
The alphabet is a convention, not a security feature. RFC 4648 also defines a URL-safe variant that swaps + for - and / for _, because the original two characters carry meaning in URLs and form bodies. JWTs use that variant and usually drop the padding as well.
Recognizing it at a glance
- Characters stay inside A-Z, a-z, 0-9,
+,/, or-and_for the URL-safe form. - The length is a multiple of four before padding removal.
- It often ends with one or two
=. - Decoding yields binary rather than structured text. If it decodes to readable JSON or a sentence, it was probably never meant to be secret.
Where Base64 belongs
Its legitimate use is transport. Inline images in CSS and HTML (data: URLs), binary attachments in email (MIME), small blobs inside JSON and JWT segments all rely on it. Each of those channels expects text, and Base64 is the cheapest way to make arbitrary bytes survive the trip.
Where it does not
Anywhere confidentiality matters. Base64 has no key, no nonce and no authentication tag. If the threat model includes someone reading your data, Base64 is a printing press, not a lock.
Two substitutions are common and both are wrong. Storing a password as Base64 is worse than storing it in plain text, because it creates a false sense of safety. "Obfuscating" an API key by encoding it just moves the secret from one string to another.
Working with it safely
// Browser: btoa accepts Latin-1 only, so encode to UTF-8 bytes first
const bytes = new TextEncoder().encode('中文文本');
const encoded = btoa(String.fromCharCode(...bytes));
const decoded = new TextDecoder().decode(
Uint8Array.from(atob(encoded), c => c.charCodeAt(0)),
);
// Node.js: UTF-8 is native
const b64 = Buffer.from('中文文本', 'utf8').toString('base64');
const back = Buffer.from(b64, 'base64').toString('utf8');
// URL-safe variant, as used by JWTs
const urlSafe = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
For large payloads, avoid spreading the byte array into String.fromCharCode. A 10 MB file becomes a ten-million-argument call and can blow the stack. Chunk it, or let FileReader.readAsDataURL do the work.
Pitfalls that actually bite
- Treating it as encryption. A single
atobundoes it, with no exceptions. - Calling
btoaon non-ASCII text. It throwsInvalidCharacterErroron anything outside Latin-1, which is why Chinese, emoji and accented text need the UTF-8 step above. - Inlining large assets. The 33% overhead is paid on every request and it blocks the parser. Keep data URLs for icons and small SVGs; serve everything else as a file.
- Assuming the length is a secret. The encoded length leaks the original size almost exactly.
Decision table
| Goal | Use | Why |
|---|---|---|
| Carry binary inside JSON, URL or email | Base64 | Reversible and keyless; it solves transport only |
| Keep data unreadable to others | AES-GCM with a random key | Not decryptable without the key, and tamper-evident |
| Detect whether a file changed | SHA-256 | One-way; a single flipped bit changes the whole digest |
| Store user passwords | bcrypt or Argon2 | Salted and deliberately slow, raising brute-force cost |
Two questions that keep coming up
Why does JWT use Base64Url? Because standard Base64 contains + and /, which get escaped or truncated in URLs. The URL-safe alphabet keeps the token safe to copy and paste, and dropping the padding keeps it shorter.
Does Base64 compress data? No. It makes data larger by roughly a third. The only thing it buys is printability.
Base64 versus URL encoding
Both make data safe for a text channel, but they solve different problems. URL encoding percent-escapes characters that are illegal or reserved in a URL, such as spaces, &, ? and non-ASCII bytes. It is reversible and positional: the output is still readable text. Base64 rewrites the entire byte stream into a 64-symbol alphabet and is designed for binary payloads. URL encoding a 10-byte string might yield 12 characters; Base64 always yields a multiple of four and pads with =. Use URL encoding for query parameters, Base64 for blobs.
A worked JWT example
A JSON Web Token has three Base64Url segments separated by dots: header, payload and signature. Decoding the first two is a single function call, which is why "the token is encrypted" is never true. The signature is what prevents tampering, and it only works if the verifier checks it against the right key and algorithm. The classic vulnerability is accepting alg: none or letting the client choose the algorithm; both turn a signed token into an editable one.
// Decoding a JWT payload is not decryption
const [header, payload] = token.split('.');
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/')),
);
// json is readable by anyone holding the token
What the 33% actually costs
The overhead is easy to dismiss until you measure it. A 1 MB image becomes about 1.37 MB of text, and that text compresses worse than the original binary. On a page loading ten such images you have added several megabytes and delayed parsing. The rule of thumb: inline assets under roughly 2 KB where an extra request would cost more than the payload, and serve everything else as a file with proper caching. For JSON APIs, Base64 fields can inflate a response enough to matter on mobile networks, so prefer multipart uploads for large files.
The takeaway
Use Base64 when a text channel is the constraint. Use encryption when secrecy is the requirement, and hashing when integrity or password storage is. The three are often used together in the same request, but they are not interchangeable, and treating one as another is how secrets end up in a log file.
Try it: Base64 codec, AES encryption, Hash calculator