AES-256 Encryption in Python: PyCryptodome, Fernet, and Choosing the Right Tool
AES-256 is the symmetric encryption standard used by governments, financial systems, and messaging apps. Symmetric means the same key encrypts and decrypts — simpler than asymmetric (RSA) and fast enough for large data. Python gives you two main libraries for this: pycryptodome for low-level control, and cryptography (Fernet) for a high-level safe API.
Installing the Libraries
pip install pycryptodomepip install cryptographyApproach 1: PyCryptodome with CBC Mode
CBC (Cipher Block Chaining) is the most commonly discussed AES mode. Each block of plaintext is XORed with the previous ciphertext block before encryption, making the output non-deterministic even for identical inputs.
You need three things: a 32-byte key, a random 16-byte IV (Initialization Vector), and padding to fill the last block.
from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpadfrom Crypto.Random import get_random_bytesimport base64
def encrypt_aes_cbc(plaintext: str, key: bytes) -> str: """Encrypt a string with AES-256 CBC. Returns base64-encoded ciphertext.""" iv = get_random_bytes(16) # random IV, not secret cipher = AES.new(key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(plaintext.encode(), AES.block_size)) # Prepend IV to ciphertext so the decryptor can read it return base64.b64encode(iv + ciphertext).decode()
def decrypt_aes_cbc(token: str, key: bytes) -> str: """Decrypt a base64-encoded AES-256 CBC ciphertext.""" raw = base64.b64decode(token) iv = raw[:16] # extract the prepended IV ciphertext = raw[16:] cipher = AES.new(key, AES.MODE_CBC, iv) return unpad(cipher.decrypt(ciphertext), AES.block_size).decode()
# Generate a random 32-byte (256-bit) keykey = get_random_bytes(32)
message = "Confidential: project launch is Q3 2026"encrypted = encrypt_aes_cbc(message, key)decrypted = decrypt_aes_cbc(encrypted, key)
print("Original: ", message)print("Encrypted:", encrypted)print("Decrypted:", decrypted)The IV does not need to be secret, but it must be unique per encryption. Prepending it to the ciphertext is the standard practice.
Approach 2: Fernet — Simpler and Safer for Most Use Cases
Fernet is a high-level encryption recipe from the cryptography library. It handles IVs, padding, and authentication automatically. It uses AES-128 under the hood (not 256), but the authentication guarantee makes it safer against misuse than a raw CBC implementation.
from cryptography.fernet import Fernet
# Generate and save the key — losing it means losing the datakey = Fernet.generate_key()print("Key (save this):", key.decode())
cipher = Fernet(key)
message = b"API secret: sk-prod-a1b2c3d4"
token = cipher.encrypt(message)print("Encrypted token:", token.decode())
recovered = cipher.decrypt(token)print("Decrypted:", recovered.decode())Fernet tokens include an HMAC, so tampering with the ciphertext causes decryption to fail with an exception rather than returning garbage. This is called authenticated encryption, and it is a meaningful security improvement over plain CBC.
Approach 3: Password-Derived Key with PBKDF2
In practice, you rarely hand a random 32-byte key to a user. Instead, derive the key from a password using a key derivation function. PBKDF2 slows down brute-force attacks by making key derivation computationally expensive.
from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpadfrom Crypto.Random import get_random_bytesfrom Crypto.Protocol.KDF import PBKDF2from Crypto.Hash import SHA256import base64
def derive_key(password: str, salt: bytes) -> bytes: """Derive a 32-byte key from a password using PBKDF2-SHA256.""" return PBKDF2(password, salt, dkLen=32, count=200_000, hmac_hash_module=SHA256)
def encrypt_with_password(plaintext: str, password: str) -> str: """Encrypt using a password. Stores salt + IV + ciphertext in one token.""" salt = get_random_bytes(16) key = derive_key(password, salt) iv = get_random_bytes(16) cipher = AES.new(key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(pad(plaintext.encode(), AES.block_size)) return base64.b64encode(salt + iv + ciphertext).decode()
def decrypt_with_password(token: str, password: str) -> str: """Decrypt a token produced by encrypt_with_password.""" raw = base64.b64decode(token) salt, iv, ciphertext = raw[:16], raw[16:32], raw[32:] key = derive_key(password, salt) cipher = AES.new(key, AES.MODE_CBC, iv) return unpad(cipher.decrypt(ciphertext), AES.block_size).decode()
password = "correct-horse-battery-staple"secret = "Database password: hunter2"
token = encrypt_with_password(secret, password)result = decrypt_with_password(token, password)print("Recovered:", result)The count=200_000 parameter makes each key derivation take roughly 0.1 seconds on modern hardware. That is fine for legitimate users logging in once, but devastating for an attacker trying millions of password guesses.
Which Approach to Use
| Situation | Recommendation |
|---|---|
| General symmetric encryption | Fernet — authenticated, handles edge cases |
| Need AES-256 specifically | PyCryptodome CBC with GCM mode (authenticated) |
| Encrypting with a user password | PBKDF2 + AES (Approach 3 above) |
| File encryption at rest | Fernet or AES-GCM |
Security note: never reuse an IV with the same key. Never store the key alongside the encrypted data. For anything production-critical, use a secrets manager (AWS Secrets Manager, HashiCorp Vault) rather than managing keys manually.