Encrypted vs. encoded: the one-line difference
Every time someone base64-encodes a password and calls it “protected”, this is the failure mode: encoding and encryption both turn readable data into less-readable data, and that similarity is exactly what makes them get confused. The difference is what each one is protecting against. Encoding changes the shape of the data so it can move through a channel that expects a different format — and anyone can undo it. Encryption changes who can read it — the only way back is the secret key.
This post walks through both in Python: a message encoded with base64, the same message encrypted with Fernet, and then the same secret shipped down a “wire” both ways so we can compare from the attacker’s seat.
Encoding: a public rule, not a lock
The mental model for encoding: it’s a fixed, publicly known transformation. Base64 reads bytes three at a time and writes them out as four characters from a fixed 64-character alphabet — that’s essentially the whole trick (the = characters are padding). Because the rule is public knowledge, decoding it requires no secret at all; you just run the rule in reverse. Let’s make that concrete with a message that someone treated as sensitive:
import base64
message = "Meet me at the dock at noon."
print("plaintext: ", message)
# Encoding is a fixed, public rule: bytes -> text. No secret involved.
encoded = base64.b64encode(message.encode()).decode()
print("base64: ", encoded)
# Undoing it needs no secret either — just the same public rule, reversed.
print("decoded: ", base64.b64decode(encoded).decode())
# Same input, same output, every single time, for anyone.
again = base64.b64encode(message.encode()).decode()
print("deterministic (same input -> same output):", again == encoded)
# Encoding also exists to make arbitrary bytes travel as plain text,
# e.g. emoji that can't live in an ASCII-only channel.
print("emoji encoded:", base64.b64encode("top secret 🚀".encode()).decode())
Running it:
plaintext: Meet me at the dock at noon.
base64: TWVldCBtZSBhdCB0aGUgZG9jayBhdCBub29uLg==
decoded: Meet me at the dock at noon.
deterministic (same input -> same output): True
emoji encoded: dG9wIHNlY3JldCDwn5qA
Two things to notice. The “decoded” line prints the original message with nothing special done — no password, no key, just base64.b64decode. If the goal had been keeping that sentence private, base64 did not help one bit; it changed the format, full stop. And deterministic: True shows the other half of the story: the same input produces the same output, every time, for anyone. That’s not a limitation of base64 specifically — a public rule is a public rule, so any encoder behaves this way.
So if it provides no secrecy, why does encoding exist at all? Because channels are picky. JSON APIs, URLs, and email attachments all want plain text, but your data may be arbitrary bytes — the emoji line above (dG9wIHNlY3JldCDwn5qA) is what makes multi-byte characters survive an ASCII-only pipeline. Encoding is a translation between representations, and its success criterion is lossless round-tripping, not confidentiality.
Encryption: the key is the point
Now the same message through Fernet, the symmetric encryption scheme from Python’s cryptography package (v50.0.1, where the project’s docs list Fernet as its “symmetric encryption” recipe). “Symmetric” is worth unpacking: the same key is used for both encrypting and decrypting, so the key is the single point that separates the message from everyone else.
from cryptography.fernet import Fernet, InvalidToken
# The key is the only secret in the whole picture.
key = Fernet.generate_key()
fernet = Fernet(key)
message = b"Meet me at the dock at noon."
token_a = fernet.encrypt(message)
token_b = fernet.encrypt(message)
print("ciphertext #1:", token_a.decode())
print("ciphertext #2:", token_b.decode())
print("same plaintext, same ciphertext?", token_a == token_b)
print()
print("decrypted with the right key:", fernet.decrypt(token_a).decode())
print()
# A *different* key cannot recover the message.
other = Fernet(Fernet.generate_key())
try:
other.decrypt(token_a)
except InvalidToken:
print("decrypted with a different key: FAILED (InvalidToken)")
Running it:
ciphertext #1: gAAAAABqoPNV3hxfDjZ60hpNDo13l-l66lmkrjcSRUEF7Gs_kZrLTvjphH26juLButTYJZ_cX9LRda_90CFtH817Vsd9nEOCp-y81rJrezUNZTOYF_WIwB4=
ciphertext #2: gAAAAABqoPNVoiIZmE9mFJ-O_cb_LtwoejHw6fK-kHLsDC-teW0PcHUysm02sQdGTu0a3qmmEaTAl75UoqlBTEvP1YuvLjrzK3jqi8xzyPsYEXwn91fvCyI=
same plaintext, same ciphertext? False
decrypted with the right key: Meet me at the dock at noon.
decrypted with a different key: FAILED (InvalidToken)
Contrast that directly with the base64 output. Encrypting the same plaintext twice produced two different ciphertexts — base64 gave us True for that comparison, Fernet gives False. The non-determinism is a feature, not noise: a scheme where identical messages always produced identical ciphertext would let a watcher confirm “that’s the same message again” without ever reading it.
Then the two decryption attempts show the actual security boundary. The right key recovers the sentence exactly; a different key doesn’t even get a wrong answer — it fails outright with InvalidToken. Notice what the attacker does know in that second attempt: the exact algorithm, the token format, and the ciphertext itself. All of it. None of it is enough. That’s the working definition of encryption, as opposed to the base64 case where knowing the algorithm is everything.
One practical consequence worth internalizing: with encoding, the tool undoes your work; with encryption, only your copy of the key does. That means key management — storing it, shipping it to the other side, keeping it out of the same log as the ciphertext — is now a real part of the problem, not an afterthought.
The same secret, two ways on the wire
The cleanest way to feel the difference is to put both payloads in the same position and ask the one question that decides it: can a stranger who knows no secrets read this? The script below generates both forms of the same secret, prints what an eavesdropper would intercept, and then plays the eavesdropper in both cases:
import base64
from cryptography.fernet import Fernet, InvalidToken
secret = "The launch code is 4471."
key = Fernet.generate_key()
# Two payloads travel over the same "wire", each protecting the
# same secret in a completely different way.
on_the_wire_base64 = base64.b64encode(secret.encode()).decode()
on_the_wire_encrypted = Fernet(key).encrypt(secret.encode()).decode()
print("channel log (this is all the eavesdropper intercepts):")
print(" base64: ", on_the_wire_base64)
print(" encrypted: ", on_the_wire_encrypted)
print()
# An eavesdropper can read the base64 payload immediately:
# undoing base64 is just knowledge, not a secret.
print("eavesdropper decodes the base64:",
base64.b64decode(on_the_wire_base64).decode())
print()
# The same eavesdropper, facing the ciphertext without the key:
try:
Fernet(Fernet.generate_key()).decrypt(on_the_wire_encrypted.encode())
except InvalidToken:
print("eavesdropper tries to decrypt the ciphertext: blocked (InvalidToken)")
Running it:
channel log (this is all the eavesdropper intercepts):
base64: VGhlIGxhdW5jaCBjb2RlIGlzIDQ0NzEu
encrypted: gAAAAABqoPPVwNf9uNOm0KPQMPC4d8PJ4RXPv4LrTXj5GcquXKr7NlzVwLqiSGmkj58Bmw_HWDUbJ5PseNbeeI300SJij7VxEsyp_1YRxHrvhrMFf5n-Z3g=
eavesdropper decodes the base64: The launch code is 4471.
eavesdropper tries to decrypt the ciphertext: blocked (InvalidToken)
Same secret, same channel, opposite outcomes. The base64 payload is read back in one trivial call — “The launch code is 4471.” — because undoing it is a matter of knowing the rule, which is public. The ciphertext resists even an attack attempt, not because the algorithm is mysterious but because the key never appeared in the intercepted data. If you find yourself writing “obfuscated” payloads for security and reaching for base64, this is the test that should stop you: it is the decoded-message case in disguise.
Which one do you actually need?
The choice comes down to the problem you’re solving. Reach for encoding when the issue is representational: getting binary data through JSON, URLs, or a text-only channel, or making a payload printable and paste-able. Base64 is the default answer there, and that’s its job. Reach for encryption when the issue is readers: the data shouldn’t be readable by anyone who intercepts it, and you can guarantee that a key exists on the other side. A useful rule of thumb: if you can name the person (or attacker) you’re protecting against, you probably need encryption; if you’re just fitting bytes into a text box, you need encoding. And the two compose without conflict — in the compare script, Fernet’s ciphertext is itself base64-encoded for transport, which is a nice reminder that a ciphertext usually still has to ride inside some encoding.
Takeaway
Encoding and encryption are both reversible, but they’re reversible for different people. Encoding is a public rule anyone can run backward — it changes the format of the data, never its audience. Encryption is reversible only for whoever holds the key — it changes the audience, and the ciphertext is what keeps the two apart. When you look at an unfamiliar blob of characters, the identifying question is the eavesdropper test: hand it to a stranger who knows the algorithm but nothing else. If they can read it, you’re looking at encoding. If they can’t, you’re looking at a key you don’t have.