Base64 is not encryption

It looks scrambled, so it gets treated as protected. It is not. What Base64 is actually for, and the silent corruption btoa causes on the way.

Someone on your team stores an API token Base64-encoded in a config file, and the review comment says it is fine because the value is "encoded". A year later that repository goes public and the token is readable by anyone who pastes it into a decoder — which is to say, by anyone.

This confusion is worth taking seriously rather than mocking, because it comes from somewhere reasonable. Base64 output looks like ciphertext. It is unreadable at a glance, it has that dense alphanumeric texture, and it often shows up in the same places real secrets do. But looking scrambled and being protected are unrelated properties, and the distance between them is where the incidents happen.

What Base64 is actually for

Base64 exists because a lot of the plumbing we still use was built to carry text, not bytes. Email is the original case: MIME had to move an image attachment through mail servers that would happily mangle any byte with the high bit set, strip nulls, or reinterpret line endings. The fix was to stop sending those bytes at all — rewrite the data using 64 characters that every system already agreed on, and let the other end reverse it.

Those 64 characters are A–Z, a–z, 0–9, plus + and /, with = as padding. RFC 4648 is the specification that pins all of that down. Three bytes of input become four characters of output, which is where the cost comes from:

The size you pay for it
3000 bytes  ->  4000 characters   (33.3% larger)

A third bigger, every time, forever. That is the trade: you give up a third of your bandwidth to guarantee the payload arrives intact through something that was never designed to carry it. When the channel can carry bytes, Base64 is pure waste — which is why sending a file as a Base64 JSON field is usually the wrong call and a multipart upload is the right one.

Encoding, hashing, encryption

Three operations that produce a similar-looking blob of characters and do entirely different jobs. Getting these straight is most of the battle.

Reversible?Needs a key?What it is for
Encoding (Base64, hex, percent)Yes, by anyoneNoSurviving a channel that will not carry your bytes
Hashing (SHA-256)No, everNoProving two things are identical without storing one
Encryption (AES)Yes, with the keyYesMaking it unreadable to everyone else

The row that surprises people is hashing. A hash is not "encryption you cannot undo" — it is deliberate, permanent information loss. Any input of any length produces the same fixed-size output, so most of the input is thrown away. That is precisely why it is right for passwords: the server can check whether you typed the same thing without ever holding the thing you typed.

So the honest answer to "should I Base64 this secret?" is that the question is about the wrong layer. If it must be unreadable, encrypt it or, better, keep it out of the file entirely and inject it at runtime. If you are only trying to stop a newline breaking your config parser, Base64 is a fine and reasonable choice — just do not describe it as security to anyone.

The bug: btoa quietly writes the wrong bytes

Now the part that actually costs people days, because nothing about it fails where you are looking.

The browser's built-in btoa predates the web settling on UTF-8. It operates on a "binary string" — one character, one byte — so it can only handle code points up to U+00FF. Modern JavaScript strings are not that. What happens next depends entirely on which non-ASCII characters you have, and the two outcomes could not be more different.

Above U+00FF — it throws, which is the good case
> btoa("日本")
Uncaught InvalidCharacterError: Failed to execute 'btoa' on 'Window': The string
to be encoded contains characters outside of the Latin1 range.

Loud, immediate, impossible to ignore. Now the same function with a German or French string, where every character happens to fit in a byte:

At or below U+00FF — no error at all
> btoa("Grüße")
'R3L832U='

> atob(btoa("Grüße"))
'Grüße'                     <- round-trips perfectly, so your tests pass

> Buffer.from("Grüße", "utf8").toString("base64")
'R3LDvMOfZQ=='              <- what every other system expects

Read those two outputs again. R3L832U= and R3LDvMOfZQ== are different data. btoa wrote ü as the single byte 0xFC — its Latin-1 value — where UTF-8 uses the two bytes 0xC3 0xBC. Nothing errored, because 0xFC is a perfectly legal byte.

The fix is to convert to UTF-8 bytes yourself before encoding, rather than letting btoa guess. In Node that is Buffer.from(s, 'utf8'); in a browser it is new TextEncoder().encode(s), then Base64 the resulting byte array. The general rule is worth internalising beyond this one function: decide your encoding explicitly at every boundary, because every default that guesses will eventually guess differently from the system on the other side.

Why there are two alphabets

Standard Base64 uses + and /, and both are hostile in a URL. A / reads as a path separator, and a + means a literal space once a query string is parsed as form data. So RFC 4648 section 5 defines a second alphabet that swaps them for - and _.

Same three bytes, two alphabets
standard:  +/++
url-safe:  -_--

This is why a token pasted from a URL sometimes refuses to decode: the decoder is expecting one alphabet and receiving the other. The tell is a stray - or _ in a blob you thought was standard, and it tells you something useful about the string's history — it travelled through a URL at some point.

Padding is the other giveaway. Base64 pads with = to a multiple of four characters, but the URL-safe form usually strips it because = has its own meaning in a query string. A JWT is the case you will meet most often — its three segments are unpadded base64url by specification, not by accident:

A real JWT payload segment
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlJvc2FsaW5kIEZyYW5rbGluIiwiaWF0IjoxNTE2MjM5MDIyfQ

decodes to:
{"sub":"1234567890","name":"Rosalind Franklin","iat":1516239022}

Which brings the whole thing full circle. That payload is not protected — it is signed, and those are different guarantees. A signature means nobody can change the contents without detection. It does not mean nobody can read them. Anyone holding that token can read every claim inside it, which is exactly why you do not put anything sensitive in one.

Base64 to Text Decodes with correct UTF-8 rather than atob, accepts both alphabets and unpadded input, and tells you which one it found — so the shape of the string tells you where it came from. Base64 to JSON Decodes the blob and reads the JSON inside it, which is the fastest way to look at a JWT payload segment or a Kubernetes secret value.

Where Base64 is genuinely the right answer

None of the above makes it a bad tool. It is the correct choice whenever a channel will only carry text and you have bytes, and it shows up constantly for exactly that reason:

  • Email attachments — the original motivation, and still how every attachment travels.
  • Data URIs — a small icon inlined into CSS or HTML as data:image/png;base64,…, saving a request.
  • Kubernetes secretskubectl get secret -o yaml shows Base64 values. That is encoding so arbitrary bytes fit in a YAML string, not protection, and the docs say so.
  • JWT segments — as above, base64url so the token survives being a URL parameter or a header.
  • Basic authentication — the Authorization: Basic header is base64 of user:password. Trivially reversible, which is why it is only acceptable over TLS.

That last one is the neatest summary of the whole article. Basic auth encodes your password in Base64 and that is not a flaw in the scheme — the encoding was never meant to hide anything. The protection comes from the TLS underneath. Take that away and the header is plaintext with extra steps.

Three questions worth asking

When you next meet a Base64 string, or are about to create one:

  1. Am I encoding, or am I hiding? If the answer is hiding, this is the wrong layer — reach for encryption, or for not storing the value at all.
  2. Who converts the text to bytes, and did I say which encoding? If btoa or any other default is guessing, an accented character will eventually cross a boundary and arrive wrong.
  3. Which alphabet, and is it padded? A - or _ means it came through a URL; missing = usually means a JWT segment. Both are information about where the string has been.

If you want to see the answers rather than reason about them, paste the string into Base64 to Text — it reports the alphabet and the padding instead of just handing back a result. And if what you are actually holding is a hash rather than an encoding, Hash Generator will show you why it can never be turned back into anything.