API keys in a config file, a CSV of customer e-mails on a shared drive, a password-reset link that should stop working after 15 minutes: all of these need real encryption, not base64 and not a home-made XOR trick. In Python the standard answer is Fernet from the cryptography package. Fernet gives you authenticated symmetric encryption with one key and two methods, encrypt() and decrypt(), and it refuses to decrypt anything that was changed or encrypted with another key. In this tutorial we encrypt a string, turn a password into a key with Scrypt, encrypt and restore a whole file, make tokens expire, and rotate keys with MultiFernet. Everything was tested on Python 3.13.5 with cryptography 50.0.2 (wheels built with OpenSSL 4.0.3), and every output block below is the real output of the script above it. Keys and tokens are random, so yours will look different.
What Fernet does (and why not roll your own)
- AES-128 in CBC mode with PKCS7 padding encrypts the data, using a fresh random IV for every message, so encrypting the same text twice gives two different tokens.
- HMAC-SHA256 signs the token. Any changed byte, or the wrong key, raises
InvalidTokeninstead of returning garbage. - A timestamp is stored inside every token, which is what makes the
ttl(time to live) check possible. - The key is 32 random bytes (16 for signing, 16 for encryption), stored as 44 characters of URL-safe base64, so it fits in an environment variable.
- Tokens are URL-safe text that start with
gAAAAA, so you can store them in a database column, a JSON file or a query string.
Encryption is not hashing. Encryption is reversible with the key; use it for data you need to read back. For login passwords you should never decrypt, store a slow password hash (Argon2, bcrypt or Scrypt) instead.
Install
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install cryptography
python -c "import cryptography; print(cryptography.__version__)"
cryptography ships prebuilt wheels for Windows, macOS and Linux (including OpenSSL), so there is nothing to compile. The current 50.x line supports Python 3.9 and newer.
Step 1: Encrypt and decrypt a string
Generate a key once, create a Fernet object, and encrypt bytes (call .encode() on strings). We also prove the two safety properties: a different key fails, and a token with one changed character fails.
from cryptography.fernet import Fernet, InvalidToken
key = Fernet.generate_key() # 32 random bytes, URL-safe base64
print("Key:", key.decode()[:12] + "...", len(key), "chars")
f = Fernet(key)
token = f.encrypt(b"Card ending 4242, PIN 7319")
print("Token:", token.decode()[:40] + "...")
print("Token length:", len(token))
print("Decrypted:", f.decrypt(token).decode())
# A different key cannot read it
try:
Fernet(Fernet.generate_key()).decrypt(token)
except InvalidToken:
print("Wrong key -> InvalidToken")
# Change one character and the HMAC check fails
bad = token[:-5] + (b"A" if token[-5:-4] != b"A" else b"B") + token[-4:]
try:
f.decrypt(bad)
except InvalidToken:
print("Tampered token -> InvalidToken")
Key: Vh3M4ydlex4M... 44 chars
Token: gAAAAABq9mEO7dWHZrWJQm1iHXf9QJwPEjNyZBAO...
Token length: 120
Decrypted: Card ending 4242, PIN 7319
Wrong key -> InvalidToken
Tampered token -> InvalidToken
A 26-byte message became a 120-character token: Fernet adds a version byte, an 8-byte timestamp, a 16-byte IV, padding and a 32-byte HMAC, then base64-encodes the result. That overhead is fixed per message, so it matters for tiny values and is negligible for files. Always catch InvalidToken rather than a broad Exception, so a wrong key is handled as the security event it is.
Step 2: Derive the key from a password with Scrypt
Fernet needs a random 32-byte key, not a human password. When a user must unlock data with a password, run the password through a key derivation function (KDF) with a random salt. Scrypt is deliberately slow and memory-hard, which makes guessing attacks expensive. The salt is not secret: store it next to the token.
import base64, os, time
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
def key_from_password(password: str, salt: bytes) -> bytes:
kdf = Scrypt(salt=salt, length=32, n=2**15, r=8, p=1)
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
salt = os.urandom(16) # store the salt next to the data
t0 = time.perf_counter()
key = key_from_password("correct horse battery staple", salt)
print(f"Derived key in {time.perf_counter() - t0:.2f} s")
token = Fernet(key).encrypt(b"db_password=S3cr3t!")
blob = salt + token # one value to save
print("Saved bytes:", len(blob))
salt2, token2 = blob[:16], blob[16:]
print("Right password:", Fernet(key_from_password("correct horse battery staple", salt2)).decrypt(token2).decode())
try:
Fernet(key_from_password("Tr0ub4dor&3", salt2)).decrypt(token2)
except InvalidToken:
print("Wrong password -> InvalidToken")
Derived key in 0.10 s
Saved bytes: 136
Right password: db_password=S3cr3t!
Wrong password -> InvalidToken
The parameters n=2**15, r=8, p=1 use about 32 MB of memory and took 0.10 seconds here, which is fine for unlocking a file and painful for an attacker trying millions of guesses. If you prefer PBKDF2, use PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=1_200_000); OWASP recommends at least 600,000 iterations for PBKDF2-HMAC-SHA256, and Django's current default is higher still. Never reuse one salt for every user, and never hard-code the password in the script.
Step 3: Encrypt and restore a whole file
Here we create a 2,000-row CSV, save a key to secret.key, write an encrypted .enc copy, decrypt it again and compare SHA-256 checksums to prove the round trip is exact.
import hashlib
from pathlib import Path
from cryptography.fernet import Fernet
src = Path("customers.csv")
src.write_text("id,name,email\n" + "".join(
f"{i},Customer {i},user{i}@example.com\n" for i in range(1, 2001)))
key_file = Path("secret.key")
key_file.write_bytes(Fernet.generate_key()) # keep this out of git!
f = Fernet(key_file.read_bytes())
enc = src.with_suffix(".csv.enc")
enc.write_bytes(f.encrypt(src.read_bytes()))
print(f"{src.name}: {src.stat().st_size:,} bytes -> {enc.name}: {enc.stat().st_size:,} bytes")
print("Encrypted file starts with:", enc.read_bytes()[:24])
out = Path("customers_restored.csv")
out.write_bytes(f.decrypt(enc.read_bytes()))
same = hashlib.sha256(src.read_bytes()).hexdigest() == hashlib.sha256(out.read_bytes()).hexdigest()
print("SHA-256 match after decrypt:", same)
print("First line:", out.read_text().splitlines()[1])
customers.csv: 76,693 bytes -> customers.csv.enc: 102,348 bytes
Encrypted file starts with: b'gAAAAABq9mEPvtzlTeGg4dKG'
SHA-256 match after decrypt: True
First line: 1,Customer 1,user1@example.com
The encrypted file is about one third larger because of base64. Fernet reads the whole file into memory, so it is ideal for config files, exports and documents up to a few hundred megabytes. For multi-gigabyte files, split them into chunks and encrypt each chunk, or use a streaming AEAD such as AESGCM from cryptography.hazmat.primitives.ciphers.aead. Add secret.key and *.key to .gitignore, and if you lose the key the data is gone for good.
Step 4: Expiring tokens and key rotation
Because each token carries its creation time, decrypt(token, ttl=900) rejects tokens older than 15 minutes, perfect for password-reset or e-mail confirmation links. To make the demo deterministic we use encrypt_at_time() and decrypt_at_time() to simulate the clock. Then MultiFernet handles key rotation: it encrypts with the first key and tries every key when decrypting, and rotate() re-encrypts an old token with the new key.
import time
from cryptography.fernet import Fernet, MultiFernet, InvalidToken
f = Fernet(Fernet.generate_key())
now = int(time.time())
# Tokens carry their creation time; ttl rejects old ones
reset = f.encrypt_at_time(b"user=42;action=reset-password", current_time=now)
print("After 10 min, ttl=900:", f.decrypt_at_time(reset, ttl=900, current_time=now + 600))
try:
f.decrypt_at_time(reset, ttl=900, current_time=now + 1200)
except InvalidToken:
print("After 20 min, ttl=900 -> InvalidToken (expired)")
# Key rotation: new key first, old key still readable
old = Fernet(Fernet.generate_key())
legacy = old.encrypt(b"api_key=sk-live-123")
new = Fernet(Fernet.generate_key())
mf = MultiFernet([new, old])
print("MultiFernet reads old token:", mf.decrypt(legacy))
rotated = mf.rotate(legacy) # re-encrypt with the new key
print("New key alone reads rotated:", new.decrypt(rotated))
try:
old.decrypt(rotated)
except InvalidToken:
print("Old key cannot read rotated token")
After 10 min, ttl=900: b'user=42;action=reset-password'
After 20 min, ttl=900 -> InvalidToken (expired)
MultiFernet reads old token: b'api_key=sk-live-123'
New key alone reads rotated: b'api_key=sk-live-123'
Old key cannot read rotated token
A safe rotation plan is: add the new key at the front of the MultiFernet list, deploy, run rotate() over every stored token, and only then remove the old key. rotate() keeps the original timestamp, so TTL checks still work on rotated tokens.
Where to keep the key
- Environment variable:
Fernet(os.environ["FERNET_KEY"])is the simplest option for servers and CI, and it keeps the key out of your code and repository. - Secret manager: AWS Secrets Manager, Azure Key Vault, Google Secret Manager or HashiCorp Vault for production systems with several services.
- Password-derived: Step 2, when a person must unlock the data and nothing should be stored on disk.
- Never in the same folder or database table as the encrypted data, and never in a public repository.
Quick reference
| Task | Code |
|---|---|
| New key | Fernet.generate_key() |
| Encrypt | Fernet(key).encrypt(data_bytes) |
| Decrypt | Fernet(key).decrypt(token) |
| Expire after 15 min | f.decrypt(token, ttl=900) |
| Token creation time | f.extract_timestamp(token) |
| Password to key | base64.urlsafe_b64encode(Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(pw)) |
| Several keys | MultiFernet([new, old]) |
| Re-encrypt with new key | MultiFernet([new, old]).rotate(token) |
| Wrong key or tampered | except InvalidToken: |
Common problems and fixes
TypeError: data must be bytes: you passed astr. Usetext.encode()to encrypt and.decode()on the result ofdecrypt().ValueError: Fernet key must be 32 url-safe base64-encoded bytes: you passed a password or a raw 32-byte value. Usegenerate_key(), or base64-encode the KDF output as in Step 2.InvalidTokenwith the right key: the token was changed (often trailing whitespace or a newline when reading from a file; call.strip()), or the TTL expired, or the server clocks differ by more than 60 seconds.- Every run gives a different token: this is correct. A random IV is used each time, so do not compare tokens to check equality; decrypt them or store a hash for lookups.
- Old tutorials pass
backend=default_backend(): thebackendargument has been optional for years and is not needed in current releases.
Where to go next
- Load
FERNET_KEYfrom the environment with type checking using pydantic-settings: Typed Env Config in Python. - See how password hashing and tokens fit into a web API in Authentication and Authorization with FastAPI in Python.
- Understand the difference between object hashing and cryptographic security in hash() in Python: Object Hashing and Hashability.
Conclusion
With the cryptography package, secure encryption in Python is a few lines: Fernet.generate_key() for the key, encrypt() and decrypt() for strings and files, Scrypt when a password has to unlock the data, ttl for links that expire, and MultiFernet for painless key rotation. Keep the key out of your code, catch InvalidToken, and you have authenticated encryption you can trust in 2026.