Intermediate

Storing passwords in plaintext, sending API keys over unencrypted channels, or using home-grown XOR “encryption” are among the most common causes of data breaches. The Python cryptography library exists precisely to make doing the right thing easy. Built on top of OpenSSL and maintained by the Python Cryptographic Authority (PyCA), it provides both a high-level “recipes” layer for common tasks and a low-level “hazmat” layer for specialists who need direct cipher access.

This article focuses on the recipes layer, which handles all the hard parts — key generation, IV selection, authentication tags — so you can encrypt and decrypt data correctly without a PhD in applied cryptography. We will cover Fernet symmetric encryption for simplicity, AES-GCM for performance-critical authenticated encryption, and RSA asymmetric encryption for key exchange and digital signatures.

Cryptography Quick Example: Fernet in 10 Lines

# quick_fernet.py
from cryptography.fernet import Fernet

key = Fernet.generate_key()          # 32-byte URL-safe base64 key
f = Fernet(key)

plaintext = b"Secret API credentials: sk-abc123"
token = f.encrypt(plaintext)
print(f"Encrypted: {token[:40]}...")

decrypted = f.decrypt(token)
print(f"Decrypted: {decrypted.decode()}")
Output:
Encrypted: gAAAAABl8x2...
Decrypted: Secret API credentials: sk-abc123

Fernet uses AES-128-CBC + HMAC-SHA256 under the hood and always produces authenticated ciphertext — if anyone tampers with the token, decryption raises InvalidToken rather than silently returning garbage.

Installing the cryptography Library

pip install cryptography

python -c "import cryptography; print(cryptography.__version__)"
Output:
42.0.5
LayerAPIUse When
Recipes (high-level)Fernet, MultiFernetDefault — handles IV, padding, authentication automatically
Hazmat (low-level)cryptography.hazmat.*Custom protocols, non-standard cipher modes, HSM integration
Fernet recipes layer mixing AES HMAC IV
The recipes layer: all the hard cryptographic choices made for you.

Fernet: Symmetric Encryption Made Simple

Fernet is the right choice when both the encryptor and decryptor share a secret key — encrypting files at rest, protecting database fields, or securing configuration secrets.

# fernet_demo.py
from cryptography.fernet import Fernet, MultiFernet
import os, base64

# --- Key generation and storage ---
key = Fernet.generate_key()
print(f"Key (store this securely): {key.decode()}")

# Save key to file (in production: use a secret manager)
with open('/tmp/fernet.key', 'wb') as kf:
    kf.write(key)

# Load key from file
with open('/tmp/fernet.key', 'rb') as kf:
    loaded_key = kf.read()

f = Fernet(loaded_key)

# --- Encrypt and decrypt ---
data = b"Database password: s3cret_p@ssw0rd"
token = f.encrypt(data)
print(f"Token length: {len(token)} bytes")

recovered = f.decrypt(token)
assert recovered == data
print(f"Round-trip OK: {recovered.decode()}")

# --- TTL: token expires after N seconds ---
import time
short_lived = f.encrypt_at_time(b"Temporary token", int(time.time()))
try:
    f.decrypt_at_time(short_lived, ttl=1, current_time=int(time.time()) + 5)
except Exception as e:
    print(f"Expired token: {type(e).__name__}")

# --- Key rotation with MultiFernet ---
old_key = Fernet.generate_key()
new_key = Fernet.generate_key()
mf = MultiFernet([Fernet(new_key), Fernet(old_key)])

old_token = Fernet(old_key).encrypt(b"Encrypted with old key")
rotated = mf.rotate(old_token)     # re-encrypts with new_key
print(f"Rotation OK: {mf.decrypt(rotated)}")
Output:
Key (store this securely): bXluWHh4...
Token length: 120 bytes
Round-trip OK: Database password: s3cret_p@ssw0rd
Expired token: InvalidSignature
Rotation OK: b'Encrypted with old key'
MultiFernet key rotation zero downtime
MultiFernet.rotate(): decrypt with old key, re-encrypt with new key — zero downtime key rotation.

AES-GCM: Authenticated Encryption for Performance

When you need more control — streaming large files, custom nonce sizes, or AEAD with associated data — use AES-256-GCM from the hazmat layer directly:

# aes_gcm_demo.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# Generate a 256-bit key
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)

# Nonce MUST be unique per encryption — never reuse with the same key
nonce = os.urandom(12)   # 96-bit nonce is standard for GCM

plaintext = b"Sensitive medical record #12345"
aad = b"patient_id:12345"  # Additional Authenticated Data (not encrypted, but authenticated)

ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
print(f"Ciphertext+tag length: {len(ciphertext)} bytes (plaintext was {len(plaintext)})")

# Decrypt — must provide same nonce and aad
recovered = aesgcm.decrypt(nonce, ciphertext, aad)
print(f"Decrypted: {recovered.decode()}")

# Tamper test: modifying ciphertext raises InvalidTag
import copy
tampered = bytearray(ciphertext)
tampered[0] ^= 0xFF
try:
    aesgcm.decrypt(nonce, bytes(tampered), aad)
except Exception as e:
    print(f"Tamper detected: {type(e).__name__}")
Output:
Ciphertext+tag length: 47 bytes (plaintext was 31)
Decrypted: Sensitive medical record #12345
Tamper detected: InvalidTag

The 16-byte difference between plaintext and ciphertext length is the GCM authentication tag. AES-GCM is significantly faster than AES-CBC because it can be parallelised and is hardware-accelerated on any modern CPU with AES-NI instructions.

RSA Asymmetric Encryption and Signatures

RSA solves the key distribution problem: the public key can be shared freely, and only the private key holder can decrypt or sign:

# rsa_demo.py
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization

# --- Key generation ---
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048
)
public_key = private_key.public_key()

# --- Serialize keys to PEM ---
pem_private = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.BestAvailableEncryption(b"passphrase")
)
pem_public = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# --- Encrypt with public key, decrypt with private key ---
message = b"Symmetric key: " + b"\x8f\xa2" * 16  # e.g. an AES key
ciphertext = public_key.encrypt(
    message,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None
    )
)
print(f"RSA ciphertext length: {len(ciphertext)} bytes")

decrypted = private_key.decrypt(
    ciphertext,
    padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
                 algorithm=hashes.SHA256(), label=None)
)
assert decrypted == message
print("RSA encrypt/decrypt: OK")

# --- Sign with private key, verify with public key ---
document = b"Invoice #1234: $5000 due 2026-06-01"
signature = private_key.sign(
    document,
    padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256()
)
print(f"Signature length: {len(signature)} bytes")

public_key.verify(
    signature, document,
    padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256()
)
print("Signature verified: OK")
Output:
RSA ciphertext length: 256 bytes
RSA encrypt/decrypt: OK
Signature length: 256 bytes
Signature verified: OK
RSA asymmetric encryption public private key
RSA: share the lock, keep the key.

Frequently Asked Questions

When should I use Fernet vs AES-GCM directly?

Use Fernet for most application-level encryption — it handles nonce generation, key derivation format, and authentication automatically, and its base64 output is easy to store in databases or environment variables. Use AES-GCM directly when you need to stream large files without loading them entirely into memory, when you need to integrate with a specific wire protocol that expects raw bytes, or when you need custom AEAD associated data patterns. Both use authenticated encryption and are equally secure in terms of the underlying algorithms.

Should I use cryptography for password hashing?

No — use bcrypt, argon2-cffi, or passlib for passwords. The cryptography library is designed for encrypting data, not for password storage. Password hashing requires deliberately slow key derivation functions that resist brute-force attacks. Encrypting a password with Fernet is wrong because if the key leaks, all passwords are immediately exposed — a proper hash is irreversible. The library does include PBKDF2HMAC and scrypt for key derivation from passwords (e.g., deriving a Fernet key from a passphrase), which is different from storing hashed passwords.

Where should I store encryption keys?

Never hardcode keys in source code or commit them to version control. In development, use environment variables or .env files excluded from git. In production, use a dedicated secret manager: AWS Secrets Manager, HashiCorp Vault, Google Cloud Secret Manager, or Azure Key Vault. For the highest security tier, use a Hardware Security Module (HSM) or cloud KMS where the key never leaves the hardware boundary. The cryptography library integrates with PKCS#11 HSMs via the hazmat layer.

How do I encrypt large files with RSA?

RSA cannot encrypt data larger than its key size minus padding overhead (about 190 bytes for a 2048-bit key with OAEP). The standard approach is hybrid encryption: generate a random AES-256 key, encrypt the file with AES-GCM, then encrypt the AES key with RSA. The recipient decrypts the RSA-wrapped AES key with their private key, then uses that AES key to decrypt the file. This combines RSA’s key distribution advantages with AES-GCM’s performance — the same pattern used by TLS, PGP, and SSH.

How do I rotate encryption keys without downtime?

For Fernet, use MultiFernet: create a new key, put it first in the list with the old key second, then call mf.rotate(old_token) on all existing ciphertext. New encryptions use the first key automatically. Once all tokens are rotated, remove the old key from the list. For AES-GCM, the same principle applies: store a key version with each ciphertext, maintain a mapping of version to key, and migrate data lazily on read or in a background job.

Conclusion

The cryptography library makes correct encryption practical in Python. For most use cases, start with Fernet — it chooses secure defaults, prevents nonce reuse, and produces tokens that are easy to store. When you need streaming, AEAD with associated data, or hardware acceleration, use AES-256-GCM from the hazmat layer. For key exchange and digital signatures, use RSA with OAEP padding for encryption and PSS padding for signatures — never use the older PKCS1v15 padding for new code.

The three non-negotiable rules are: always use authenticated encryption (Fernet and AES-GCM both satisfy this), never reuse a nonce with the same key, and store keys outside your application code in a secret manager or environment variable. With these rules in place, the cryptography library handles the rest.