Encoding vs. encrypting: what actually makes the difference
New developers often write “encrypted” when they mean “base64’d” — and if you ship that in a real system, you’ve protected nothing. Both operations take readable data and turn it into unreadable bytes, and both are undone by a call with the same name flipped (encode/decode, encrypt/decrypt), so from the outside they look like the same trick. They are not. The difference isn’t how the bytes look; it’s who can turn them back.
Encoding: a format, not a secret
An encoding is a purely mechanical translation between representations — text to bytes, bytes to a safe transport form. Base64 is the classic example: it packs every 3 bytes of input into 4 characters from a small, fixed alphabet, so the output can travel through systems that only handle plain ASCII. Nothing about that mapping is secret. The same table is printed in man pages, pasted in Stack Overflow answers, and shipped in every standard library on earth.
So the “decoder” isn’t a secret at all — it’s the function itself. Anyone with the base64 string can undo it, no credentials required. Here’s the smallest version, round-tripping a message that includes non-ASCII characters:
import base64
message = "The quick brown fox \u72d0\u72d8"
print("1) the original message")
print(message)
print()
print("2) encode: text -> bytes (UTF-8) -> a safer transport form (base64)")
encoded_bytes = message.encode("utf-8")
encoded = base64.b64encode(encoded_bytes)
print("UTF-8 bytes :", encoded_bytes)
print("base64 text :", encoded)
print()
print("3) anyone can decode, no secret needed")
decoded = base64.b64decode(encoded).decode("utf-8")
print("decoded :", decoded)
print("round-trip ok:", decoded == message)
Running it:
1) the original message
The quick brown fox 狐狘
2) encode: text -> bytes (UTF-8) -> a safer transport form (base64)
UTF-8 bytes : b'The quick brown fox \xe7\x8b\x90\xe7\x8b\x98'
base64 text : b'VGhlIHF1aWNrIGJyb3duIGZveCDni5Dni5g='
3) anyone can decode, no secret needed
decoded : The quick brown fox 狐狘
round-trip ok: True
Note what happens in step 3: the decoding needs nothing but the encoded string. The original message is fully recoverable by a stranger who just found the value in your database or log file — because that’s the whole design. Encoding solves “can’t represent these bytes in this context,” not “don’t want people to read this.”
Encryption: the key is the difference
Encryption looks the same at first glance — readable text in, unreadable bytes out — but it adds one ingredient the encoder didn’t need: a key. The key is random, it’s not derivable from the ciphertext, and the decryption algorithm can’t produce the original text without it. Everything else about the scheme is public knowledge; the secrecy lives entirely in one party holding that 32-byte value.
Python’s standard path for this is Fernet, from the cryptography package. It hides the underlying cipher’s complexity behind two calls, and it gives us a clean way to feel the difference: encrypt the same secret twice, try to read the result as base64, then try a wrong key.
import base64
from cryptography.fernet import Fernet, InvalidToken
message = "Transfer $5,000 to the wire account."
key = Fernet.generate_key()
fernet = Fernet(key)
print("1) the secret message")
print(message)
print()
print("2) the key (a random 32-byte value, base64-encoded)")
print(key)
print()
print("3) encrypt (base64 key in, base64 token out)")
token = fernet.encrypt(message.encode("utf-8"))
print("ciphertext:", token)
print()
print("4) same message, second encryption -> different ciphertext")
token2 = fernet.encrypt(message.encode("utf-8"))
print("ciphertext2:", token2)
print()
print("5) decode it like base64? the raw bytes are still unreadable junk")
raw = base64.urlsafe_b64decode(token)
print("raw bytes :", raw)
try:
raw.decode("utf-8")
except UnicodeDecodeError:
print("and those bytes are NOT valid UTF-8 text")
print()
print("6) decrypt with the correct key")
print(fernet.decrypt(token).decode("utf-8"))
print()
print("7) decrypt with a different key")
wrong = Fernet(Fernet.generate_key())
try:
wrong.decrypt(token)
except InvalidToken as e:
print("InvalidToken:", e)
One run looked like this (the key and ciphertexts are random, so yours will differ — the behavior is the point):
1) the secret message
Transfer $5,000 to the wire account.
2) the key (a random 32-byte value, base64-encoded)
b'fxHepKZogusIikm7U4Xh4a5yT4-rIwr8SXK_7wCJ_7w='
3) encrypt (base64 key in, base64 token out)
ciphertext: b'gAAAAABqoRb-PMpmdEl_NT4lQ0AWnWRpz1K3RTrI5bUaF-8H1guBy2QhqeIFDf8UecXHxan3wBUaITgTYbR50f9aLER7A7EGvkVaiPFMrZKj2pqd1qJaflh81tPd4UqDAyijusd0EQDJ'
4) same message, second encryption -> different ciphertext
ciphertext2: b'gAAAAABqoRb-Iu8IY63NC5hDlWHDQPU4xbanv9RzmRrlTb788EOu9bmPH4lqvB0V3RrM8V2oVt8wu3N5Y5L9wnGHpPOcay0jM1cBPS8xm39ROB9wdN5fxlVHs5oEKJ85Cqfbv5zAapi7'
5) decode it like base64? the raw bytes are still unreadable junk
raw bytes : b'\x80\x00\x00\x00\x00j\xa1\x16\xfe<\xcaftI\x7f5>%C@\x16\x9ddi\xcfR\xb7E:...' (truncated here)
and those bytes are NOT valid UTF-8 text
6) decrypt with the correct key
Transfer $5,000 to the wire account.
7) decrypt with a different key
InvalidToken:
Three things in that output do the heavy lifting:
- Step 4: same message, different ciphertext. Encrypting the identical string twice produced two different tokens. Encoding never does this — encode the same text twice and you get the same base64, always. This nondeterminism comes from randomness in the scheme (a fresh timestamp and IV each time;
Fernet.generate_keyisos.urandom(32)per thecryptographylibrary’s ownfernet.pysource), and it’s a feature: it means you can’t even tell two records were encrypted from the same value. - Step 5: “decoding” gets you nowhere. The token is base64-shaped (Fernet emits urlsafe base64 — the same
base64.urlsafe_b64encodecall appears infernet.py), but the bytes underneath are not text and not structured in any way that reveals the message. This is exactly the trap the junior version of this post warns about: if your “encryption” can be undone by a stranger with a standard library call, it was encoding. - Step 7: a wrong key is a hard stop.
InvalidTokenisn’t “got the message slightly wrong” — the HMAC built into Fernet means a wrong key fails before any plaintext is even considered. Encryption is a gate with a check, not a transformation you can partially invert.
The one asymmetry worth feeling: decoding the base64 string in step 3 of the first example needed zero credentials, and decrypting in step 6 needed exactly one. That’s the entire difference, made concrete.
Takeaway
Encoding is a format change — reversible by definition, by anyone, for everyone, because the “secret” is a public algorithm and the only requirement is knowing it’s base64 and not hex or UTF-8. Encryption is a confidentiality boundary — the bytes look similar, but undoing them requires a key that no algorithm can reconstruct. The quick test when you’re unsure what a value in your codebase is: if a stranger with Python stdlib can turn it back into readable text, it was only ever encoded, and it was never protecting anything at all.
And one practical consequence: Fernet’s generate_key() makes a new random key every call, so in a real app you generate it once and store it somewhere safe (a secret manager, an environment variable) — if the key and the ciphertext are in the same place, you’ve effectively just picked a more decorative encoding.