The simplest encryption you can write yourself is a Caesar cipher (shift each letter by a fixed amount) or an XOR cipher (combine each byte with a key byte). Both take under ten lines of Python, and both are good for learning and useless for protecting real data. Below are working implementations, a demonstration of how quickly each breaks, and the short, correct way to encrypt something for real.
Encryption in one paragraph
Encryption turns plaintext into ciphertext using an algorithm and a key; decryption reverses it with the key. With symmetric encryption the same key does both jobs. With asymmetric encryption (RSA, elliptic curves) there is a public key to encrypt and a private key to decrypt. Everything here is symmetric. Two other things get confused with encryption: encoding (Base64, hex), which is reversible by anyone and uses no key, and hashing (SHA-256), which is one-way.
The Caesar cipher
Each letter moves a fixed number of places along the alphabet, wrapping from Z to A. The shift is the key. Julius Caesar is reported to have used a shift of 3.
def caesar(text, shift):
out = []
for ch in text:
if ch.isascii() and ch.isalpha():
base = ord("A") if ch.isupper() else ord("a")
out.append(chr((ord(ch) - base + shift) % 26 + base))
else:
out.append(ch) # keep digits, spaces, punctuation
return "".join(out)
secret = caesar("Meet me at noon, Cafe 9!", 3)
print(secret)
print(caesar(secret, -3))
Output:
Phhw ph dw qrrq, Fdih 9!
Meet me at noon, Cafe 9!
Three details matter. The % 26 handles wrap-around, and Python’s % returns a non-negative result for a positive modulus, so a negative shift also works and decryption is just encryption with -shift. The ch.isascii() check matters because "é".isalpha() is true in Python, and without the check an accented letter would be mapped to garbage. And case is preserved by choosing the base letter per character.
Why it is broken
There are only 25 useful keys, so an attacker tries them all:
for k in range(1, 26):
print(k, caesar("Khoor, Zruog!", -k))
Among the 25 lines, shift 3 prints Hello, World!, and a human or a dictionary check spots it at once. Even if the key were unknown and the text long, letter frequencies give it away, because “e” stays the most common letter, just relabeled.
XOR cipher
XOR (^) has the property (a ^ k) ^ k == a. So the same function encrypts and decrypts. Repeat the key to cover the message length:
def xor_bytes(data: bytes, key: bytes) -> bytes:
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
msg = "XOR is symmetric, café!".encode("utf-8")
key = b"SECRET"
enc = xor_bytes(msg, key)
print(enc.hex())
print(xor_bytes(enc, key).decode("utf-8"))
Output:
0b0a11722c2773363a3f283127372a31697430242591ec75
XOR is symmetric, café!
Work with bytes, not strings, and show ciphertext as hex or Base64. A common mistake (and a bug in many tutorials) is to call .decode() on the XOR result: the output bytes are arbitrary and usually are not valid UTF-8, so decoding raises UnicodeDecodeError or corrupts the data.
Why it is broken
- Known plaintext. If an attacker knows or guesses part of the message (a file header, “Dear”, a JSON key), XORing it with the ciphertext reveals the key:
xor_bytes(enc[:6], msg[:6])printsb'SECRET'. - Key reuse. XORing two ciphertexts made with the same key cancels the key and leaves the XOR of the two plaintexts, which is easy to pick apart.
- Short repeating keys create a repeating pattern that can be found statistically (this is a Vigenère-style cipher).
XOR is secure in exactly one case: the one-time pad, where the key is truly random, as long as the message, and never used twice. secrets.token_bytes(len(msg)) gives a suitable key, but you have to share a key as big as the data safely, so it is rarely practical. Real stream ciphers such as ChaCha20 use a short key to generate a long keystream that does not repeat, and they add authentication.
Base64 is not encryption
Base64 turns bytes into text made of 64 safe characters. It is used to put binary data into JSON, URLs or emails. Anyone can reverse it:
import base64
s = base64.b64encode(b"hello").decode()
print(s, base64.b64decode(s)) # aGVsbG8= b'hello'
Storing a password or token as Base64 is the same as storing it in plain text. This mistake appears in real code reviews and security reports.
What to use for real data
Use a well-reviewed library with a high-level interface. In Python the cryptography package (install with pip install cryptography) offers Fernet, which handles the algorithm choice (AES-128-CBC with an HMAC), random initialization vectors and tamper detection for you:
from cryptography.fernet import Fernet
key = Fernet.generate_key() # store this securely, not in your repo
f = Fernet(key)
token = f.encrypt(b"my secret")
print(f.decrypt(token)) # b'my secret'
try:
Fernet(Fernet.generate_key()).decrypt(token) # wrong key
except Exception as e:
print(type(e).__name__) # InvalidToken
A wrong key, or a modified token, raises InvalidToken instead of returning garbage. That integrity check is what the toy ciphers lack. For other cases:
- Passwords: never encrypt them; hash them with a slow, salted function such as scrypt, bcrypt or Argon2 (
hashlib.scryptis in the standard library). - Data in transit: use TLS (HTTPS); do not roll your own protocol.
- Keys from passwords: derive them with a key derivation function (PBKDF2, scrypt, Argon2), not by using the password directly.
- Random values for keys and tokens: use
secretsoros.urandom, neverrandom. - Modes: if you use AES directly, choose an authenticated mode such as GCM and never reuse a nonce with the same key.
Rules worth remembering
- Do not invent your own cryptography for anything that matters; security comes from public, heavily scrutinized algorithms and a secret key, not from a secret algorithm.
- The key is the weak point. Keep it out of source control, in environment variables or a secrets manager, and plan for rotation.
- Encryption without authentication allows undetected tampering. Prefer authenticated encryption.
- Keep the three ideas straight: encoding is not encryption, and encryption is not hashing.
Practice
Good exercises: write a Caesar cracker that picks the most plausible shift by letter frequency, extend the XOR cipher to recover a repeating key from a known phrase, or implement the Vigenère cipher and then break it. Caesar-style problems also appear in coding interviews as string manipulation. The Python lessons on accessing characters and string immutability cover what you need, and our overview of string algorithms goes further. For real systems, read the documentation of the cryptography library before writing any code.
