Base64 Is Not Encryption — And Four Places That Assumption Costs You
Base64 has no key, so it protects nothing. But the interesting part is not the slogan — it is what goes wrong in Basic Auth, in your logs, in btoa() with Vietnamese text, and when a standard decoder meets a JWT.

Every developer learns "Base64 is not encryption" at some point, usually as a correction. The slogan is true and it is also where most articles stop, which is a shame, because the slogan is the least useful part.
The useful part is the four specific places the assumption bites.
Why it protects nothing
Encryption takes a key. Without the key you get nothing back, and that property is the whole point.
Base64 takes no key. The transformation is fully specified in RFC 4648: three bytes go in, they are split into four six-bit groups, and each group is looked up in a 64-character alphabet. Anyone holding the string runs the table backwards and has your bytes. There is nothing to break because nothing was locked.
What it is actually for is moving binary through channels that only accept text — email bodies, JSON string fields, HTTP headers, data URIs, PEM certificate files. Raw bytes break those channels. Base64 survives them.
It belongs in the same category as hexadecimal. Nobody claims hex protects anything.
1. HTTP Basic Auth is Base64, and that is the whole story
This is the one that surprises people, because the credential arrives in a header called Authorization and looks scrambled:
Authorization: Basic YWRtaW46aHVudGVyMg==
That decodes to admin:hunter2. The Basic scheme takes username:password, joins them with a colon, and Base64-encodes the result. That is the entire mechanism.
So Basic Auth over plain HTTP is not "weak authentication" — it is sending the password in a form anyone on the path reads instantly. It is safe over TLS, and the safety comes from TLS, not from the encoding. The encoding is only there because a raw password could contain bytes that would break the header.
Worth internalising, because the same shape appears everywhere: a value that looks scrambled in a security-sounding place, doing no security work at all.
2. Base64 in logs defeats your own redaction
This one is quieter and does more damage.
Log pipelines scrub sensitive data by pattern. Something shaped like an email, a card number, a bearer token — the scrubber recognises it and masks it. That is how PII stays out of your log store.
Base64 an email address before logging it and the pattern is gone. The scrubber sees an opaque string and lets it through. The value is still fully readable to anyone who pastes it into a decoder, which is to say anyone with log access.
You have not hidden the data. You have hidden it from your own safeguards while leaving it perfectly legible to a reader. That is strictly worse than logging it in the clear, because in the clear at least your redaction would have caught it.
If a value is sensitive, do not log it. Encoding is not a middle ground.
3. btoa() has two failure modes, and the quiet one ships
Vietnamese demonstrates both, which is convenient, because they are not equally bad.
btoa("Tiếng Việt");
// Uncaught DOMException: InvalidCharacterError
btoa("Xin chào");
// "WGluIGNo4G8=" ← no error. also wrong.
btoa predates JavaScript's current string model — it came from the original Netscape window object — and treats each character's code point as a single byte. That works only for code points 0 through 255.
In Tiếng Việt, the ế and ệ sit at U+1EBF and U+1EC7, far outside the range, so it throws. Loud, immediate, fixed the same afternoon.
In Xin chào, the à is U+00E0 — decimal 224, comfortably inside the range. No error. It encodes as the single Latin-1 byte E0, and anything that decodes the result as UTF-8 gets mojibake where the à should be. The correct output is WGluIGNow6Bv.
That second one is the dangerous case: it passes code review, passes tests written in English, and surfaces weeks later as corrupted names in a database. Any language whose accented characters happen to live in Latin-1 — Vietnamese, French, Spanish, German — will produce it.
The fix is to produce UTF-8 bytes first, then encode those:
const encode = (str) =>
btoa(String.fromCharCode(...new TextEncoder().encode(str)));
const decode = (b64) =>
new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
encode("Xin chào"); // "WGluIGNow6Bv"
encode("Tiếng Việt"); // "VGnhur9uZyBWaeG7h3Q="
Two notes. The spread in String.fromCharCode(...bytes) blows the call stack on large inputs — chunk it, or use the native API below. And if you have unescape(encodeURIComponent(...)) in your codebase, that is the deprecated version of this same fix; replace it.
4. Two alphabets, and the decoder that only knows one
RFC 4648 defines two alphabets. Section 4 is standard Base64. Section 5 is the URL- and filename-safe variant, and they differ in exactly two characters:
| Index 62 | Index 63 | Padding | |
|---|---|---|---|
| Standard (§4) | + | / | = present |
| base64url (§5) | - | _ | conventionally dropped |
The decoded bytes are identical. Only two of sixty-four characters change.
But + and / are both meaningful inside a URL, and = is the key-value separator in a query string — a value ending data=SGVsbG8= makes naive parsers choke on the second equals sign. So anything that travels in a URL uses the second alphabet: JWTs, OAuth tokens, WebPush keys.
RFC 4648 section 3.2 permits dropping padding when the length is known, and RFC 7515 goes further — for JWTs it requires the trailing equals signs be omitted.
Which produces the single most common Base64 failure: pasting a JWT segment into a standard decoder and getting an error. Repair it first:
const fromBase64Url = (s) =>
atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
Pickrack's Base64 Encoder has a URL-safe toggle for exactly this, so you can decode a token segment without doing the substitution by hand. For a whole token, the JWT Decoder splits and repairs all three segments for you — and we wrote separately about whether it is safe to paste a JWT into an online decoder at all.
The 33 percent nobody budgets for
Three bytes become four characters. That is a fixed 33 percent expansion before padding.
It stops being trivia when you inline images as data URIs or push files through JSON. A 3 MB upload arrives as roughly 4 MB of request body — through your rate limiter, your body-size cap, your logging middleware and your bandwidth bill. Base64-in-JSON file uploads hit a surprising number of 413 responses for this reason alone.
Multipart form data sends bytes as bytes. Use it when you can.
The API that removes all of this
TC39 has a proposal that puts Base64 on Uint8Array directly, which sidesteps the Latin-1 problem by never involving a string, and takes an alphabet option so URL-safe needs no replace chain:
new TextEncoder().encode("Xin chào").toBase64();
// "WGluIGNow6Bv"
Uint8Array.fromBase64(token, { alphabet: "base64url" });
Feature-detect before you rely on it. Availability is further behind than the write-ups suggest — checking on Node v22.22.0, both Uint8Array.prototype.toBase64 and Uint8Array.fromBase64 are still undefined. Test the runtime you actually deploy to rather than trusting a version number:
const hasNative = typeof Uint8Array.prototype.toBase64 === "function";
Keep the TextEncoder version above as the fallback. It works everywhere today.
Bottom line
Base64 is a transport format. It has no key, so it protects nothing, and treating it as protection creates a specific set of failures rather than a vague one:
- A Basic Auth header is a password in a thin costume — TLS is doing all the work
- Encoding before logging blinds your redaction, not your reader
btoathrows on anything past Latin-1 and, worse, quietly corrupts the accented characters that fall inside it- A URL-safe string needs repairing before a standard decoder will touch it
When you need actual protection: TLS in transit, authenticated encryption at rest, a secret manager for credentials. Base64 can wrap the ciphertext afterwards for transport. That order is correct. The reverse is not a weaker version of it — it is nothing at all.
Encode, decode, or repair a URL-safe string: Pickrack Base64 Encoder. Browser-side, so the string never leaves your tab.
Related Articles
WCAG Color Contrast 2026: Every Threshold, and the 3:1 Rule Teams Keep Missing
Five contrast thresholds, not two — and the one that catches most teams is not about text at all. Plus what changed on 28 June 2025, when contrast stopped being a design preference in the EU.
Is It Safe to Paste a JWT Into an Online Decoder? (2026)
Decoding a JWT needs no key, so decoding itself is harmless. The risk is that a signed token is a live credential — and 'we process client-side' is a claim you cannot see. Here is the 20-second test that settles it.