~/blog

Base64 is not encryption — stop treating it like a secret

published

#security#base64#encoding

Three labeled boxes left to right — ENCODE, HASH, ENCRYPT — with the ENCODE box showing a keyless round-trip from hunter2 to its base64 form and back, marked with an open padlock.

TL;DR

Base64 turns arbitrary bytes into 64 safe ASCII characters so binary survives a text-only channel. It has no key, so decoding is just the reverse mapping — atob() in a browser, one shell pipe. A base64 string is exactly as secret as the bytes you fed in: not at all. If something only “protects” data by base64-encoding it, it protects nothing. The real protection in Basic Auth, JWTs, and Kubernetes Secrets comes from TLS, signatures, and access control respectively — not from the encoding.

The problem

The same pattern shows up in code review after code review: a token, a password, or a config value gets base64-encoded and treated as though it’s now hidden.

$ echo -n 'hunter2' | base64
aHVudGVyMg==

$ echo -n 'aHVudGVyMg==' | base64 -d
hunter2

No key. No salt. No effort. The encoded form is trivially reversible by design — that’s the whole point of an encoding. People conflate “I can’t read it at a glance” with “an attacker can’t read it,” and those are not remotely the same statement.

Three places this misunderstanding does real damage:

Why it happens

The confusion is that three different operations all turn readable input into unreadable-looking output:

OperationReversible?Needs a key?PurposeExample
Encoding (base64)Yes, by anyoneNoMove bytes through a text channel safelybtoa(), base64
Hashing (SHA-256)NoNoFixed-size fingerprint / integritypassword storage (with salt), checksums
Encryption (AES)Yes, with the keyYesConfidentialityTLS, disk encryption

Only encryption provides confidentiality, and it’s the only one of the three that requires a key. Base64 sits at the opposite end: deliberately keyless and public. If your threat model includes anyone seeing the bytes, base64 does nothing for you.

What to do

Pick the tool that matches the goal:

Get the encoding details right so base64 doesn’t bite you in other ways:

// Browser btoa/atob operate on Latin-1, so they throw on multibyte UTF-8.
// Encode to bytes first, then base64. (RFC 4648 §4)
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode('café 🚀')));
const text = new TextDecoder().decode(
  Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
);

For anything that goes in a URL or filename, use base64url (RFC 4648 §5): - and _ replace + and /, and padding is dropped. JWTs use this variant — which is why a raw atob() sometimes chokes on a JWT segment.

If you just need to eyeball what’s inside an encoded blob, decode it locally: bytefork’s base64 encoder/decoder and JWT decoder both run entirely in the browser — paste a token, read the payload, nothing is sent anywhere. Which, fittingly, is the whole point: if a tool can show you the contents that easily, so can an attacker.

Caveats

References