If we had to pick the single confusion that causes the most real-world vulnerabilities among developers, it would be this one: treating encoding (like Base64) or obfuscation (like ROT13) as if they were encryption. All three transform data, and all three produce "weird-looking text" at a glance, but only encryption offers a security guarantee backed by a secret key. In this lesson we'll draw the distinction rigorously, practice with the encodings you'll see constantly (Base64, hexadecimal, URL-encoding, UTF-8) using Python's standard library, and analyze a code-review case at MediNube where someone "protected" health data with Base64. Mastering this distinction is a prerequisite for everything that follows.
Contents
- Three transformations that get confused
- Encoding: representing information
- Text and bytes: UTF-8, always the first step
- Base64 and hexadecimal in Python
- URL-encoding and URL-safe Base64
- Obfuscating: making things harder without a key
- Encrypting: protecting with a secret key
- Comparison table and decision criterion
- Case study: a code review at MediNube
Three transformations that get confused
All three operations transform an input into a different output, but for radically different purposes:
- Encoding: changing the representation of data so it can be transported or stored in a particular medium. There's no secret involved: anyone who knows the format (which is public and standard) can recover the original. Examples: UTF-8, Base64, hexadecimal, URL-encoding.
- Obfuscating: making casual reading harder, without a secret key. Anyone who knows (or guesses) the method recovers the original effortlessly. Examples: ROT13, JavaScript minification, "reversing the string."
- Encrypting: transforming data with an algorithm and a secret key, such that recovering the original without the key is computationally infeasible, even if the attacker knows the algorithm in full detail.
graph TD
T[Data transformation] --> COD[Encoding]
T --> OFU[Obfuscation]
T --> CIF[Encryption]
COD --> COD1["Goal: represent/transport<br/>Reversed by: knowing the public format"]
OFU --> OFU1["Goal: hinder casual reading<br/>Reversed by: knowing the method"]
CIF --> CIF1["Goal: confidentiality<br/>Reversed by: ONLY with the secret key"]
There's a single discriminating question: what does someone need to reverse the transformation? If the answer is "nothing secret, just knowledge of the format or the method," it isn't encryption. Period.
Encoding: representing information
Computers only handle bytes. "Encoding" means agreeing on how we represent something (text, an image, a cryptographic key) as bytes, or how we represent bytes in a medium that doesn't accept all of them. Two typical situations:
- From text to bytes: the text "María" has to become bytes to be stored or transmitted → UTF-8.
- From arbitrary bytes to safe text: a cryptographic key is 32 random bytes that include non-printable values; to fit them into a JSON field, a URL, or an environment variable we need to represent them with "harmless" characters → Base64 or hexadecimal.
This second situation explains why Base64 turns up everywhere in cryptography: the output of encrypting, signing, or hashing is arbitrary bytes, and nearly every interchange format (JSON, HTTP, XML, PEM certificates, JWT) is text-based. Base64 is the "shipping container" for those bytes. Seeing Base64 near cryptography is normal; believing Base64 is the cryptography is the mistake.
Text and bytes: UTF-8, always the first step
In Python 3 the distinction between str (text) and bytes is strict, and cryptographic functions always work with bytes. The conversion is done with UTF-8:
text = "Medical record of Ana Pérez — Clínica Sol"
data = text.encode("utf-8") # str -> bytes
print(data)
# b'Medical record of Ana P\xc3\xa9rez \xe2\x80\x94 Cl\xc3\xadnica Sol'
recovered = data.decode("utf-8") # bytes -> str
print(recovered == text) # TrueLook closely at the result:
- The
b'...'prefix indicates abytesobject. - ASCII characters ("Medical", spaces) take up 1 byte and are shown as-is.
- The
éturns into two bytes (\xc3\xa9) and the dash—into three (\xe2\x80\x94): UTF-8 uses between 1 and 4 bytes per character. That's why "text length" and "byte length" don't match — a detail that will matter when we talk about key and message sizes. .decode("utf-8")reverses the operation exactly. There's no secret involved: UTF-8 is a public standard.
Practical rule for the whole course: before any cryptographic operation, text → bytes with .encode("utf-8"); after decrypting, bytes → text with .decode("utf-8").
Base64 and hexadecimal in Python
Base64
Base64 represents bytes using only 64 safe characters (A-Z, a-z, 0-9, +, /, plus = as padding). Every 3 input bytes turn into 4 output characters, so the result is about ~33% larger.
import base64
data = "Patient: Ana Pérez, allergy: penicillin".encode("utf-8")
encoded = base64.b64encode(data)
print(encoded)
# b'UGF0aWVudDogQW5hIFDDqXJleiwgYWxsZXJneTogcGVuaWNpbGxpbg=='
decoded = base64.b64decode(encoded)
print(decoded.decode("utf-8"))
# Patient: Ana Pérez, allergy: penicillinPoint by point:
b64encodetakesbytesand returnsbytes(the Base64 characters, in ASCII). If you need astrfor a JSON field, add.decode("ascii").- The trailing
=is padding: since the input is processed in groups of 3 bytes, if the total isn't a multiple of 3 the output is padded with=so its length is a multiple of 4. b64decodereverses the operation without needing anything secret. Any person, tool, or attacker decodes Base64 instantly; even browsers ship a built-in function for it (atob).
How do you spot Base64 at a glance? Strings of upper/lowercase letters and digits, maybe with +//, a length that's a multiple of 4, and often one or two = at the end. Recognizing it is a useful skill in security reviews.
Hexadecimal
Hexadecimal represents each byte as two 0-9a-f characters. It takes up twice the size of the original bytes (worse than Base64) but is more readable and very common for hashes, keys, and identifiers:
import binascii
data = "Sol".encode("utf-8")
in_hex = binascii.hexlify(data)
print(in_hex) # b'536f6c' (S=0x53, o=0x6f, l=0x6c)
print(binascii.unhexlify(in_hex)) # b'Sol'
# Modern alternative, no import needed:
print(data.hex()) # '536f6c'
print(bytes.fromhex("536f6c")) # b'Sol'hexlify/unhexlify(thebinasciimodule) and thebytes.hex()/bytes.fromhex()methods do the same thing; the latter are more idiomatic in modern Python.- Once again: a public transformation, reversible by anyone, zero security.
| Encoding | Alphabet | Size overhead | Typical use |
|---|---|---|---|
| UTF-8 | — (text ↔ bytes) | 1-4 bytes per character | All text |
| Base64 | 64 characters + = |
+33% | Carrying bytes in JSON, HTTP, PEM, JWT |
| Hexadecimal | 16 characters | +100% | Hashes, keys, debugging |
| URL-encoding | %XX |
Variable | Data inside URLs |
URL-encoding and URL-safe Base64
URLs only allow a subset of characters. URL-encoding (or percent-encoding) replaces problematic characters with % followed by their hexadecimal value:
from urllib.parse import quote, unquote query = "surname=Pérez&clinic=Clínica Sol" print(quote(query)) # surname%3DP%C3%A9rez%26clinic%3DCl%C3%ADnica%20Sol print(unquote(quote(query)) == query) # True
quoteencodes:=becomes%3D, the space becomes%20, andébecomes%C3%A9(its two UTF-8 bytes, each with its own%XX! — encodings stack on top of each other).unquotedecodes. Public and reversible, as always.
Standard Base64 uses + and /, which clash with URL-encoding. That's why the URL-safe Base64 variant exists (it uses - and _), ubiquitous in web tokens and JWT (module 6):
import base64, os token_bytes = os.urandom(16) # 16 random bytes (we'll cover this in 01-03) print(base64.urlsafe_b64encode(token_bytes)) # e.g. b'x3K9-fT_2mQlZ0aVb1cN4g==' — safe to paste into a URL
Obfuscating: making things harder without a key
Obfuscation aims to keep data from being read "at a glance," but there's no secret key: knowing the method is enough to reverse it. The canonical example is ROT13, a Caesar cipher with a shift of 13 (applying it twice returns the original):
import codecs message = "Confidential results" obfuscated = codecs.encode(message, "rot13") print(obfuscated) # Pbasvqragvny erfhygf print(codecs.decode(obfuscated, "rot13")) # Confidential results
Notice the telltale detail: ROT13 lives in Python's codecs module, right alongside the encodings. Even the language itself doesn't treat it as security. Does obfuscation have legitimate uses? Yes, but none of them are strong security:
- Hiding spoilers in forum posts (classic ROT13).
- Minifying/obfuscating JavaScript: makes it (a little) harder to copy the frontend's business logic and reduces its size; any pretty-printing tool and a bit of patience reverse it.
- Keeping a value from showing up in plaintext under a casual
grep.
Obfuscation can add friction on top of real measures; the problem is using it instead of real measures. We'll pick this idea back up as "security through obscurity" in lesson 01-04.
Encrypting: protecting with a secret key
Encryption is qualitatively different: the transformation depends on a secret key, and the guarantee is that without it, recovering the plaintext is computationally infeasible even if the attacker has the ciphertext, knows the algorithm down to the last detail, and has massive hardware at their disposal. Security doesn't come from the method being unknown, but from the key space being astronomical (we'll quantify this in 01-03) and the algorithm not leaking patterns.
We're not going to encrypt anything for real just yet — the actual algorithms (AES, ChaCha20) are the subject of module 2, with the cryptography library — but let's lay out the contract now:
encrypt(plaintext, key) -> ciphertext # unreadable without the key decrypt(ciphertext, key) -> plaintext # only with THE SAME guarantee: the key
And a practical consequence that brings us full circle to Base64: the ciphertext is arbitrary bytes, so when you see real encryption in APIs and files, it will almost always travel additionally encoded in Base64. Encoding and encryption don't compete: they complement each other, each playing its own role.
Comparison table and decision criterion
| Property | Encoding | Obfuscation | Encryption |
|---|---|---|---|
| Purpose | Represent / transport | Hinder casual reading | Confidentiality |
| Requires a secret key? | No | No | Yes |
| Who can reverse it? | Anyone who knows the format (public) | Anyone who knows or figures out the method | Only whoever has the key |
| Reversible? | Yes, always and exactly | Yes (trivially, by design) | Yes, but only with the key |
| Security guarantee | None | None (just friction) | Formal and quantifiable (bits of security) |
| Examples | UTF-8, Base64, hex, URL-encoding | ROT13, minification | AES, ChaCha20 (module 2) |
Decision criterion in one sentence: if the requirement is that an unauthorized party can't read the data, the only option is to encrypt. Encode to transport; obfuscate, if anything, to annoy; encrypt to protect.
Case study: a code review at MediNube
Your first week at MediNube. During a code review you come across this pull request, which addresses the requirement "patients' national ID numbers must not be stored in plaintext in the database":
import base64
def protect_id(id_number: str) -> str:
"""Protects the patient's ID number before storing it in the database."""
return base64.b64encode(id_number.encode("utf-8")).decode("ascii")
def recover_id(protected_id: str) -> str:
return base64.b64decode(protected_id).decode("utf-8")
# Stored in the patients table:
print(protect_id("12345678Z")) # MTIzNDU2NzhaThe author argues: "the ID number is no longer visible in the database." Let's analyze this as reviewers:
- What does it actually do? It Base64-encodes the value. The column will show
MTIzNDU2Nzhainstead of12345678Z. At a glance it looks "protected." - What does an attacker need to reverse it? Nothing. If they steal a database dump, one line (
base64.b64decode(...)) or any "base64 decode" website hands back every ID number. There's no key; there's no security. It's equivalent to storing the value in plaintext wearing a disguise. - Is it even worse than doing nothing? In a sense, yes: the name
protect_iddocuments a protection that doesn't exist. In an audit, someone might mark the requirement as satisfied. False security disables the alarms. - What should be done instead? Real encryption with a key managed outside the database — exactly what we'll build in module 2 (authenticated encryption) and module 6 (encryption at rest and key management). The correct review comment is: "Base64 is encoding, not encryption: it doesn't satisfy the confidentiality requirement. Blocking this PR; let's address it with authenticated encryption and a key kept outside the DB."
Compliance note: a national ID number is personal data, and combined with a healthcare context, the whole record becomes especially sensitive under the GDPR. In a real system, decisions like this must go through security and compliance professionals; this case is a simplified teaching example.
This pattern (Base64 presented as protection) shows up in real systems with depressing frequency: in "secret" headers, in configuration files, in homegrown tokens. From today on, you'll spot it in seconds.
Common Mistakes and Tips
- "It's in Base64, so it's encrypted." The all-star mistake. Base64 decodes without a key, always, by anyone. If you can reverse it without a secret, it isn't encryption.
- Mixing up "encode/decode" with "encrypt/decrypt." Many APIs use both side by side, and reading
encodewhile thinking "encrypt" causes real misunderstandings. - Forgetting that crypto functions want
bytes. TheTypeError: Unicode-objects must be encoded...will haunt you until you internalizestr.encode("utf-8")→ operate →bytes.decode("utf-8"). - Using standard Base64 inside URLs. The characters
+,/, and=get corrupted or need escaping; usebase64.urlsafe_b64encodefor tokens that travel in URLs. - Dismissing encoding as "irrelevant to security." Encoding bugs do cause vulnerabilities (double decoding, UTF-8 mix-ups), and real cryptographic formats (PEM, JWT) are layers of Base64 over bytes; understanding the packaging keeps you from confusing it with the contents.
- Tip: in any code review, when you see a function called
protect*,encrypt*, orhide*, look inside. If there's no secret key involved, it protects nothing.
Exercises
Exercise 1. Classify each item as encoding, obfuscation, or encryption, and justify it in one line: (a) storing MediNube's admin panel password in the source code as cGFzc3dvcmQxMjM=; (b) the text of a spoiler warning transformed with ROT13; (c) a medical-record file that can only be opened with a randomly generated 256-bit key; (d) the bytes \xc3\xa9 representing the letter é; (e) MediNube's frontend JavaScript run through a minifier.
Exercise 2. You intercept (in a MediNube test environment) this string sent by a legacy app: eyJ1c2VyIjogImFuYS5wZXJleiIsICJyb2xlIjogInBhdGllbnQifQ==. (a) Write the Python code that decodes it. (b) What does it contain? (c) What security problem does it imply that the app uses this as a "session token" as-is?
Exercise 3. Write a function bytes_to_json(data: bytes) -> str that prepares arbitrary bytes (for example, the output of some future encryption) to be included in a JSON field, and its inverse json_to_bytes(field: str) -> bytes. Test it with os.urandom(32). Why can't you put the bytes directly into the JSON?
Solutions
Solution 1. (a) Encoding (Base64 of password123): the password is in plaintext for all practical purposes — a double mistake: a credential in the code and nonexistent "protection." (b) Obfuscation: prevents accidental reading, reversible by anyone who knows ROT13. (c) Encryption: there's a secret key, and without it the content is inaccessible. (d) Encoding (UTF-8): pure representation of text as bytes. (e) Obfuscation: makes reading harder, but all the logic is still there and recoverable.
Solution 2.
import base64, json
token = "eyJ1c2VyIjogImFuYS5wZXJleiIsICJyb2xlIjogInBhdGllbnQifQ=="
decoded = base64.b64decode(token).decode("utf-8")
print(decoded)
# {"user": "ana.perez", "role": "patient"}
data = json.loads(decoded)(b) A JSON object with the user and their role. The classic tell: Base64 strings that start with eyJ are almost always an encoded JSON object ({" encodes to eyJ), something ubiquitous in JWT (module 6). (c) The "token" is simply the encoded data, with no key and no verification: anyone can forge a token by changing "role": "patient" to "role": "doctor" and re-encoding it in Base64. There's neither authenticity nor integrity. The real solution is signed tokens, which we'll build in module 6 after learning HMAC (module 3) and signatures (module 4).
Solution 3.
import base64, os, json
def bytes_to_json(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def json_to_bytes(field: str) -> bytes:
return base64.b64decode(field)
future_key = os.urandom(32)
document = json.dumps({"material": bytes_to_json(future_key)})
recovered = json_to_bytes(json.loads(document)["material"])
assert recovered == future_keyYou can't put the bytes in directly because JSON only allows Unicode text in its strings, and 32 random bytes almost certainly contain sequences that aren't valid UTF-8 (on top of that, json.dumps rejects bytes objects outright). Base64 is exactly the standard bridge for this — which is why you'll see it wrapping every piece of cryptographic material in this course.
Conclusion
You now have the criterion that separates the three transformations: encoding represents (UTF-8, Base64, hex, URL-encoding — public and reversible by anyone), obfuscating only annoys (ROT13, minification — no key, no guarantee), and encrypting protects with a secret key and a computational guarantee. You've practiced with base64, binascii, and urllib.parse, you know why Base64 wraps almost all cryptographic material without being cryptography, and you've blocked your first PR at MediNube for confusing encoding with encryption.
Now, we've said that all of encryption's strength lies in the secret key... but where does a good key actually come from? What makes a value truly unpredictable? That's the foundation of absolutely everything else, and it's the subject of the next lesson: randomness and entropy.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
