Base64 is encoding, not encryption
On this page
- What base64 actually does
- Confusion #1 — “I base64’d the password before storing it”
- Confusion #2 — “I base64’d the API key in the env var”
- Confusion #3 — “I base64’d the cookie value to obscure it”
- Confusion #4 — “I base64’d the file to make it private”
- Confusion #5 — “I base64’d the JSON before sending it over HTTPS”
- When base64 is the right answer
- The mental model
- Try it
Base64 is a way to represent arbitrary bytes using only 64 ASCII characters. Anyone with the encoded string can recover the original bytes in microseconds, with no key, on any computer. There is nothing secret about a base64 string.
This is obvious to anyone who’s read the spec. It is somehow not obvious in production. Here are five places engineers confuse encoding with encryption — and what each costs.
What base64 actually does
Base64 takes 3 bytes of input and produces 4 ASCII characters of
output. It uses A–Z, a–z, 0–9, +, and /, plus = for padding. The
URL-safe variant swaps + and / for - and _. That’s all of it.
Decoding is the inverse: 4 characters back into 3 bytes. No key. No choice. The mapping is fixed in the RFC.
The use cases base64 is correct for:
- Embedding binary in a text-only protocol. Email (MIME), JSON, URL parameters, HTTP headers — all of these are text. To carry an image, a binary blob, or a hash digest through them, encode it.
- Data URIs.
data:image/png;base64,iVBOR...is the only way to inline a binary asset into HTML or CSS. - JWT segments. The header and payload of a JWT are base64url-encoded JSON; the signature is base64url-encoded bytes.
In every one of those, base64 is making bytes transportable over a text-only medium. It is not making them private.
Confusion #1 — “I base64’d the password before storing it”
The intent is “I made the password not look like the password”. The result: when an attacker dumps the database, they have a base64 string, which they decode in 100 milliseconds, and now they have the password.
What you wanted is hashing with a slow, salted algorithm — bcrypt, scrypt, or Argon2id. The hash is one-way (you can’t recover the password from it) and has a built-in cost factor (you can tune how slow verification is, to thwart brute force).
Cost: every user’s password leaks the day the database leaks. Often this is also a regulatory disaster — base64 storage of passwords typically violates SOC 2, PCI DSS, and GDPR’s “appropriate technical measures” clause.
Fix:
DON'T: stored = base64(password)
DO: stored = argon2id(password, salt = random32bytes(), m=64MB, t=3, p=4)
Hash the password. Store the hash. Throw away the plaintext.
Confusion #2 — “I base64’d the API key in the env var”
Same energy as #1. The intent is “this string in .env looks more
secure”. The result: anyone with read access to the file (which is
everyone with shell access to the box) decodes it in milliseconds.
What you wanted is to store the key in a secret manager — Vault, AWS Secrets Manager, GCP Secret Manager, 1Password CLI, Doppler — and have your app fetch it at boot. The secret manager handles encryption at rest, access control, audit logs, and rotation.
If a secret manager is overkill for your scale, the next-best option
is to encrypt the env file itself with sops or age and decrypt
on deploy.
Cost: a leaked env file = leaked credentials, full stop.
Confusion #3 — “I base64’d the cookie value to obscure it”
Cookies are text-only, so to put non-ASCII bytes in one, base64 is
appropriate. But teams sometimes base64 a JSON cookie value
(base64({user_id: 42, role: "admin"})) on the assumption that
“users won’t look inside”.
Users do look inside. A browser extension shows cookie values
formatted, decoded, base64-decoded automatically. A determined user
edits the cookie to {user_id: 1, role: "admin"}, re-encodes,
sends it, and is now logged in as user 1.
What you wanted is a signed cookie (HMAC-signed) or a session ID that points at a server-side session. The signed cookie pattern is what JWT does for the same use case — the signature is the security primitive, not the encoding.
Cost: privilege escalation by anyone who reads the docs.
Confusion #4 — “I base64’d the file to make it private”
Showing up in the wild: SaaS tools that store user uploads as base64 in a database column “for privacy”. Or apps that base64-encode a file before storing it on disk to “obfuscate” it.
The base64 string is the file. Anyone who can read the column or the file has the file. Encoding is not a permission system.
What you wanted: file storage with access controls (S3 bucket policies, presigned URLs, server-side encryption with a KMS key) and actual encryption at rest if the data is sensitive.
Cost: false sense of security; same data exposure as plaintext.
Confusion #5 — “I base64’d the JSON before sending it over HTTPS”
The HTTPS layer already encrypts everything in transit. Base64-encoding the body adds 33% to the payload size and zero security. (33% because base64 represents 3 bytes as 4 characters.)
The exception: HTTP headers can’t carry arbitrary bytes, so a binary header must be base64-encoded. That’s a transport-layer requirement, not a security one.
Cost: bandwidth. On hot APIs, the 33% bloat shows up in egress bills and TTFB.
When base64 is the right answer
To be clear — base64 is correct, even essential, in many places:
- Encoding image bytes for a
data:URI in CSS. - Encoding the segments of a JWT (the JWT decoder uses base64url to split a token apart).
- Encoding a binary blob to attach to a JSON request body (where multipart upload isn’t an option).
- Encoding HMAC signatures for transmission in headers.
- Encoding the SHA-256 of a script for a Subresource Integrity hash.
In every one of those: the security comes from somewhere else (HTTPS for confidentiality, HMAC for integrity, the hash function itself for authenticity). Base64 is just the envelope.
The mental model
Encoding is how the data looks on the wire. Encryption is whether anyone except the intended recipient can read it. They are orthogonal concerns. A message can be:
- Plaintext, unencoded —
Hello - Plaintext, base64-encoded —
SGVsbG8=(still readable to anyone) - Ciphertext, raw bytes — non-printable
- Ciphertext, base64-encoded —
e3JhbmRvbX0=(encoded for transport; still secret because it’s encrypted)
Picking the wrong one for the wrong reason is what produces the bugs above.
Try it
Encode and decode at base64.tooljo.com — both standard and URL-safe variants, plus file-to-data-URI for the common image case. It’s a static page, so nothing you paste leaves your browser.