Alright, let’s talk about cryptography. Not the “I read a Wikipedia article” kind, but the “I need to actually use this without getting fired” kind. Python’s cryptography library is your new best friend. It’s the one that actually gets it right, leaving the old pycrypto dumpster fire in the dust. We’re going to focus on its two workhorses: Fernet for when you just want it to work, and the raw AES/RSAs for when you need to get your hands dirty.

Fernet: Your Cryptographic Golf Cart

Fernet is symmetric encryption for people who have better things to do than worry about IVs and authentication tags. It’s a spec that bundles AES-128 in CBC mode with PKCS7 padding and an HMAC-SHA256 for authentication. That’s a mouthful, so let me translate: it encrypts your data, then slaps a cryptographic seal on it. If anyone messes with the ciphertext, it’ll blow up on decryption instead of giving you garbage data. This is crucial.

Here’s how you use it. It’s almost insultingly simple.

from cryptography.fernet import Fernet

# Key generation. DO THIS ONCE. STORE THIS SECURELY.
# This isn't a password. It's the whole master key.
key = Fernet.generate_key()
print(f"Your key (base64 encoded): {key.decode()}")
# Example output: 'q_DM6u3sawu3xev_iIQ9WX1Z5vV4G55p2n3WQ0a_zzM='

# Use the key to create a Fernet instance
fernet = Fernet(key)

# Your super secret message
message = "My launch codes are 12345... wait."

# Encrypt it. This returns bytes.
token = fernet.encrypt(message.encode())
print(f"Encrypted token: {token}")

# Decrypt it later
decrypted_message = fernet.decrypt(token)
print(decrypted_message.decode())  # Output: My launch codes are 12345... wait.

See? No fuss. The token includes the IV, the ciphertext, and the HMAC, all neatly packaged. Now, the best part: let’s see what happens if some joker tries to tamper with it.

# Let's tamper with the token
token_list = list(token)
token_list[10] = token_list[10] ^ 1  # Flipping a single bit
tampered_token = bytes(token_list)

try:
    fernet.decrypt(tampered_token)
except cryptography.fernet.InvalidToken:
    print("Boom! InvalidToken exception. The message was tampered with.")

This is why you use Fernet over rolling your own AES. The authentication is built-in and automatic. The main pitfall? Key management. That key is a master key. Lose it, and your data is gone. Expose it, and all your data is readable. Store it somewhere safe like a secrets manager, not in your codebase.

Raw AES: When You Need the Sports Car

Sometimes Fernet’s happy path isn’t enough. Maybe you need to handle a specific format or have peculiar performance constraints. For this, we drop down to the hazmat (Hazardous Materials) layer. This name is not a joke. It’s the library authors telling you, “You better know what you’re doing, because it’s very easy to blow your foot off here.”

Let’s encrypt something with AES in GCM mode, which provides both confidentiality and authentication, like Fernet, but gives you more control.

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os

# GCM requires a nonce (Number used ONCE). It doesn't have to be secret, but it MUST be unique for every encryption operation with the same key.
nonce = os.urandom(12)  # 96 bits is standard for GCM
key = os.urandom(32)     # 256-bit key for AES-256

# Your data. Note the associated data - this will be authenticated but not encrypted.
# Think: packet headers you need to validate but don't need to hide.
data = b"Secret payload data"
associated_data = b"Contextual data (e.g., user ID, version number)"

# Build a cipher object and encryptor
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce), backend=default_backend())
encryptor = cipher.encryptor()

# Feed associated data first (if you have any)
encryptor.authenticate_additional_data(associated_data)

# Encrypt the data and finalize
ciphertext = encryptor.update(data) + encryptor.finalize()

# Get the tag for authentication. This is your seal.
tag = encryptor.tag

# Now, to send everything: nonce, ciphertext, tag, and associated_data.
# The associated_data is sent in the clear!

Decryption is the same process in reverse. You must provide every single piece correctly.

# On the receiving end, you have: nonce, ciphertext, tag, associated_data
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce, tag), backend=default_backend())
decryptor = cipher.decryptor()

# First, feed the associated data for verification
decryptor.authenticate_additional_data(associated_data)

# Then, decrypt the data. This will automatically check the tag at the end.
# If ANY bit of the ciphertext, tag, associated_data, or nonce is wrong, this blows up.
decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()

print(decrypted_data == data)  # True

The pitfalls here are deep and nasty. Reuse a nonce with the same key in GCM? Catastrophic. Fail to validate the tag? attackers can modify your data at will. Use CBC without a HMAC? We call that “padding oracle attack practice.” This is why hazmat is aptly named. You are responsible for getting every detail right.

RSA: The Asymmetric Hammer

Asymmetric crypto is different. You have a key pair: a private key you guard with your life, and a public key you can give to anyone. It’s slower than symmetric crypto, so we mostly use it for two things: encrypting tiny things (like a symmetric key) or creating digital signatures.

Let’s say you want to send me a secret message. You’d use my public key to encrypt a freshly generated Fernet key, and then use that Fernet key to encrypt your actual message.

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

# Generate a private key
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
    backend=default_backend()
)

# Get the public key from the private key
public_key = private_key.public_key()

# Let's say you have my public key. You'd encrypt a symmetric key with it.
# RSA can only encrypt data smaller than its key size. For a 2048-bit key, that's 245 bytes.
symmetric_key = os.urandom(32) # A 256-bit AES key

# ENCRYPT with the PUBLIC key
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

ciphertext = public_key.encrypt(
    symmetric_key,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)

# You send me this ciphertext. I DECRYPT it with my PRIVATE key.
recovered_key = private_key.decrypt(
    ciphertext,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)

print(recovered_key == symmetric_key) # True

The other huge use case is signing. I can sign a message with my private key, and anyone with my public key can verify it came from me and wasn’t altered.

# I create a signature for a message
message = b"Instructions: launch all nukes"
signature = private_key.sign(
    message,
    padding.PSS(
        mgf=padding.MGF1(hashes.SHA256()),
        salt_length=padding.PSS.MAX_LENGTH
    ),
    hashes.SHA256()
)

# You verify it with my public key
try:
    public_key.verify(
        signature,
        message,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH
        ),
        hashes.SHA256()
    )
    print("Signature is valid. The message is authentic.")
except cryptography.exceptions.InvalidSignature:
    print("DANGER! The signature is invalid. Do not trust this message.")

The biggest RSA pitfall? People trying to encrypt large files with it. Don’t. It’s slow and creates huge ciphertext. Use it to encrypt a symmetric key, then use AES to encrypt your actual data. That’s the TLS/SSL model for a reason. It works.