Skip to main content

Cryptography Best Practices in Python: Secure Implementation

Cryptography is the science of securing information by converting it into code that only authorized parties can read. It is fundamental to protecting sensitive data in transit (HTTPS) and at rest (encrypted databases). Most Python developers do not need to implement cryptographic algorithms from scratch; instead, they should use well-vetted libraries like the cryptography package and follow established best practices. The main pitfalls are using weak algorithms (RC4, DES), reusing encryption keys, mishandling initialization vectors (IVs), and building custom crypto code. This article covers the most common cryptographic operations in Python applications.

Symmetric Encryption: AES with cryptography

Symmetric encryption uses a single shared key to both encrypt and decrypt data. AES-256 (Advanced Encryption Standard with 256-bit keys) is the standard for protecting sensitive data at rest. The cryptography library provides a high-level Fernet interface and a low-level AES interface. For most applications, Fernet is safer because it handles IV generation, padding, and authentication automatically:

# Simple symmetric encryption with Fernet
from cryptography.fernet import Fernet

# Generate a key (do this once, then store it securely)
key = Fernet.generate_key()
print(key) # b'...' (32 bytes, base64-encoded)

cipher = Fernet(key)

# Encrypt a message
plaintext = b"My sensitive data"
ciphertext = cipher.encrypt(plaintext)
print(ciphertext) # b'gAAAAABmX...' (ciphertext with timestamp and IV)

# Decrypt
decrypted = cipher.decrypt(ciphertext)
assert decrypted == plaintext

Fernet uses AES-128 in CBC mode with HMAC for authentication. For stronger security (AES-256), use the low-level interface:

# AES-256 encryption with explicit IV
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import os

def encrypt_aes_256_gcm(plaintext: bytes, key: bytes, aad: bytes = b"") -> tuple[bytes, bytes]:
"""Encrypt with AES-256-GCM (Galois/Counter Mode).

Args:
plaintext: Data to encrypt.
key: 32-byte encryption key.
aad: Additional authenticated data (e.g., user ID, timestamp).

Returns:
(nonce, ciphertext) — store both with the encrypted data.
"""
nonce = os.urandom(12) # 96-bit nonce for GCM
cipher = Cipher(
algorithms.AES(key),
modes.GCM(nonce),
backend=default_backend()
)
encryptor = cipher.encryptor()
encryptor.authenticate_additional_data(aad)
ciphertext = encryptor.update(plaintext) + encryptor.finalize()

return nonce, ciphertext + encryptor.tag # GCM appends a 16-byte tag

def decrypt_aes_256_gcm(nonce: bytes, ciphertext_with_tag: bytes, key: bytes, aad: bytes = b"") -> bytes:
"""Decrypt AES-256-GCM."""
ciphertext = ciphertext_with_tag[:-16] # Separate ciphertext from tag
tag = ciphertext_with_tag[-16:]

cipher = Cipher(
algorithms.AES(key),
modes.GCM(nonce, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
decryptor.authenticate_additional_data(aad)
plaintext = decryptor.update(ciphertext) + decryptor.finalize()

return plaintext

# Usage
key = os.urandom(32) # 256-bit key
plaintext = b"Confidential information"
aad = b"user_id:42" # Authenticate the user ID

nonce, ciphertext_with_tag = encrypt_aes_256_gcm(plaintext, key, aad)
decrypted = decrypt_aes_256_gcm(nonce, ciphertext_with_tag, key, aad)
assert decrypted == plaintext

AES-GCM provides both encryption and authentication (authenticated encryption), preventing attackers from modifying ciphertext. Always use authenticated encryption (GCM, ChaCha20-Poly1305) rather than unauthenticated modes (CBC without HMAC).

Password Hashing: Argon2 Instead of Bcrypt

While bcrypt is reliable, Argon2 is a modern password hashing algorithm that is more resistant to GPU and ASIC attacks. Use argon2-cffi:

# Password hashing with Argon2
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHash, VerifyMismatchError

hasher = PasswordHasher(
time_cost=2, # Number of passes (higher = slower)
memory_cost=65536, # Memory in KB (higher = more secure)
parallelism=4, # Parallelization factor
)

# Hash a password
password = "SecurePassword123!"
password_hash = hasher.hash(password)

# Verify a password
try:
hasher.verify(password_hash, password)
print("Password is correct")
except VerifyMismatchError:
print("Password is incorrect")

Argon2's parameters (time_cost, memory_cost, parallelism) should be tuned to your hardware. Higher values increase security at the cost of slower hashing. Aim for ~100–200 milliseconds per hash.

Generating Secure Random Data

Many security operations require high-quality random data: encryption keys, nonces, salts, tokens. Use os.urandom() or the secrets module:

# Generate secure random data
import os
from secrets import token_hex, token_urlsafe

# 32-byte encryption key
encryption_key = os.urandom(32)

# 16-byte nonce
nonce = os.urandom(16)

# Secure random token for password reset links
reset_token = token_urlsafe(32) # URL-safe base64-encoded

# Secure random hex string for API keys
api_key = token_hex(32)

# Never use random.random() or random.choice() for security
import random
random.seed(42) # INSECURE — predictable
insecure_token = ''.join(str(random.choice(range(10))) for _ in range(32))

os.urandom() and the secrets module use the operating system's cryptographically secure random number generator (e.g., /dev/urandom on Linux, CryptGenRandom on Windows). Never use random.random() for security purposes; it is predictable and designed for simulations, not cryptography.

Digital Signatures: Verify Data Integrity

Digital signatures prove that data has not been modified and came from a specific sender. Use RSA or EdDSA (Ed25519):

# Digital signatures with RSA
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding

# Generate a private key (do this once and store securely)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
public_key = private_key.public_key()

# Sign data
message = b"Important document"
signature = private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)

# Verify signature
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")
except InvalidSignature:
print("Signature is invalid or data was modified")

RSA is secure but slower than EdDSA. For newer applications, prefer EdDSA:

# Digital signatures with EdDSA (faster, smaller keys)
from cryptography.hazmat.primitives.asymmetric import ed25519

private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()

signature = private_key.sign(b"Important document")
public_key.verify(signature, b"Important document")

Deriving Keys from Passwords

When users set a password, derive an encryption key from it using a key derivation function (KDF). Never use the password directly as a key:

# Key derivation from password
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
from cryptography.hazmat.primitives import hashes

password = b"UserPassword123!"
salt = os.urandom(16)

kdf = PBKDF2(
algorithm=hashes.SHA256(),
length=32, # 256-bit key
salt=salt,
iterations=100000, # NIST recommends 100k+ (2024)
)

key = kdf.derive(password)

# Store salt + derived key
# Later, when the user logs in:
kdf = PBKDF2(
algorithm=hashes.SHA256(),
length=32,
salt=salt, # Retrieve stored salt
iterations=100000,
)
derived_key = kdf.derive(user_provided_password)
# Compare derived_key with stored key

PBKDF2 is slower than bcrypt at password hashing, but it is suitable for deriving encryption keys from passwords. Use bcrypt for password storage, and PBKDF2 for key derivation.

Key Takeaways

  • Use AES-256-GCM for symmetric encryption; always use authenticated encryption modes.
  • Hash passwords with Argon2 (or bcrypt); never use MD5, SHA-1, or unsalted algorithms.
  • Use os.urandom() or secrets for generating encryption keys, nonces, and tokens; never use random.random().
  • Implement digital signatures (RSA or EdDSA) to prove data integrity and authenticity.
  • Derive encryption keys from passwords using PBKDF2 with high iteration counts (100,000+).
  • Never implement cryptographic algorithms yourself; use the cryptography library.

Frequently Asked Questions

Should I use RSA or EdDSA for signatures?

EdDSA (Ed25519) is faster and has smaller keys (32 bytes vs 256 bytes for RSA-2048). For new applications, prefer EdDSA. RSA is still secure and more widely supported in legacy systems.

How do I store encryption keys?

Never hardcode keys in source code. Use environment variables, vaults (AWS Secrets Manager), or hardware security modules (HSMs) for production. For development, use .env files with restrictive permissions (e.g., chmod 600).

Is TLS/HTTPS enough, or do I need application-level encryption?

TLS encrypts data in transit. Use application-level encryption (AES-256) for sensitive data at rest (in databases). Some regulations (HIPAA, PCI DSS) require application-level encryption in addition to TLS.

Can I use CBC mode instead of GCM?

CBC mode requires explicit HMAC for authentication. GCM provides both encryption and authentication in one mode, reducing the risk of implementation errors. Prefer GCM.

How do I rotate encryption keys?

Generate a new key, decrypt all data with the old key, re-encrypt with the new key, and then delete the old key. For large datasets, this is expensive; design systems to support multiple keys (indicate which key was used per record).

Further Reading