Why bother encrypting?
If your application stores anything sensitive, a password, an API key, a personal detail, this data should be safely stored. When data leaks from your application or database we still want to secure the data as much as possible.
Encryption solves this by transforming your data into something unreadable without the correct key. In this post we will look at Fernet, a symmetric encryption scheme from Python’s cryptography library. It is simple, secure by default, and fast to implement.
What is symmetric encryption?
Symmetric encryption uses the same key to both encrypt and decrypt data. One key, two directions. This is different from asymmetric encryption like RSA, which uses a public and private key pair. This means assymetric encryption uses two different keys for encrypting and decrypting. Symmetric encryption is faster and more suited to encrypting data at rest rather than securing a network handshake.

Fernet also guarantees that encrypted data cannot be silently tampered with. It includes authentication under the hood, so if the ciphertext gets modified, decryption fails rather than returning corrupted data.
Installation
pip install cryptography
Generating a key
Fernet keys are URL-safe base64-encoded 32-byte values. You generate one like this:
from cryptography.fernet import Fernet
key = Fernet.generate_key() print(key)
Important: Treat this key like a password. Store it in an environment variable or .env file, never in your source code. Lose the key and you lose access to your data. If it leaks your encryption becomes useless. You should not store it on the same database/channel as your encrypted data itself.
Although I generally like using Django and a settings file, in this example I use dotenv which you can install using pip install python-dotenv
# .env
FERNET_KEY=your-generated-key-here
We can now import and retrieve our key:
import os from dotenvimport load_dotenv load_dotenv() key = os.environ.get("FERNET_KEY")
We can make a utils file specifically for fernet cryptography, this will allow us to easily call the functionality when I need it:
import os
from cryptography.fernet import Fernet
def _get_fernet() -> Fernet:
key = os.environ.get("FERNET_KEY")
if not key:
raise ValueError("FERNET_KEY environment variable is not set.")
return Fernet(key.encode())
def encrypt(value: str) -> str:
return _get_fernet().encrypt(value.encode()).decode()
def decrypt(encrypted: str) -> str:
return _get_fernet().decrypt(encrypted.encode()).decode()
A few things worth noting here. Fernet works with bytes, not strings, so .encode() converts the input before encryption and .decode() brings it back after. The _get_fernet() helper keeps key loading in one place.
Key rotation using MultiFernet
At some point you may need to replace your encryption key without losing access to already-encrypted data. Fernet handles this with MultiFernet.
You pass a list of keys. For all new encryption we use the first key. On decryption we attempt all keys to ensure compatibility with old data. Key rotation is useful when you want old keys to expire, this improves security and reduces the risk of your current/new key leaking.
from cryptography.fernet import Fernet, MultiFernet
def _get_fernet() -> MultiFernet:
raw_keys = os.environ.get("FERNET_KEYS", "").split(",")
keys = [Fernet(k.strip().encode()) for k in raw_keys if k.strip()]
return MultiFernet(keys)
def rotate(encrypted: str) -> str:
return _get_fernet().rotate(encrypted.encode()).decode()
Your `.env` would look like:
FERNET_KEYS=new-key-here,old-key-here
Once you have re-encrypted all existing records using rotate(), you can safely drop the old key from the list.
Wrapping up
Fernet gives you solid symmetric encryption with very little setup. Store your key outside your codebase, use MultiFernet if you ever need key rotation, and encrypted data in your database becomes a non-issue even if someone gets access to it.
In a follow-up post we will wire this directly into a Django model so fields can be encrypted and decrypted transparently on save and load.
