Skip to main content

Crypto Best Practices in Python: Common Pitfalls & Fixes

Cryptography appears simple on the surface: choose an algorithm, import a library, encrypt some data. In practice, dozens of small mistakes accumulate into security vulnerabilities. Real-world breaches often stem not from broken algorithms, but from misuse: reusing IVs, using weak random number generators, hardcoding keys, or trusting unvalidated certificates. This article catalogs the most common pitfalls in Python cryptographic code and shows how to fix them.

The principle is straightforward: use vetted libraries (cryptography, bcrypt, hashlib), follow established patterns without modification, and understand the cryptographic reasoning behind each decision. Deviating from best practices is the path to vulnerability.

Pitfall 1: Using Weak Random Number Generators

Cryptographic randomness is different from general-purpose randomness. Python's random module is predictable and designed for simulations, not security. Using it for key generation, IVs, or salts weakens encryption to the point of breakage.

# WRONG: Weak randomness for key generation
import random

key = bytes(random.randint(0, 255) for _ in range(32))
# An attacker can predict this key because random uses a seed-based PRNG

# CORRECT: Cryptographically secure randomness
import os

key = os.urandom(32) # 256 random bytes from the OS entropy pool
# Unguessable, even with knowledge of how the key is generated

The os.urandom() function uses the OS's entropy source (e.g., /dev/urandom on Linux, CryptGenRandom on Windows). It is suitable for all cryptographic purposes.

The secrets module (added in Python 3.6) is a simpler interface for cryptographic randomness:

import secrets

# Generate a cryptographically secure random token
token = secrets.token_urlsafe(32) # 32 random bytes, base64-encoded
print(token) # 'kB7x1mN2qP4rZ8vQ5sY_aB3dE6fG9hJ'

# Generate a random integer for cryptographic purposes
random_int = secrets.randbelow(1000000)

Fix: Always use os.urandom() or secrets for cryptographic randomness. Never use random.random(), uuid.uuid4(), or any weak source.

Pitfall 2: Reusing IVs in Symmetric Encryption (GCM Mode)

In GCM mode, reusing an initialization vector (IV) with the same key completely breaks security. An attacker can recover the encryption key or decrypt messages.

from cryptography.fernet import Fernet
import os

key = Fernet.generate_key()
cipher = Fernet(key)

# WRONG: Reusing the same IV for multiple encryptions
iv = os.urandom(12) # Generate once
plaintext1 = b"Message 1"
plaintext2 = b"Message 2"

# If Fernet allowed manual IV specification (it doesn't, by design),
# this would be catastrophic:
# ciphertext1 = encrypt_with_iv(plaintext1, iv) # NEVER DO THIS
# ciphertext2 = encrypt_with_iv(plaintext2, iv) # IV reused!

# CORRECT: Fernet generates a new IV for each encryption
ciphertext1 = cipher.encrypt(plaintext1) # New IV generated
ciphertext2 = cipher.encrypt(plaintext2) # New IV generated (different)

Fernet and most libraries handle this correctly by generating a new IV automatically. The risk appears when you use lower-level APIs like cryptography.hazmat.primitives.ciphers.Cipher:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os

key = os.urandom(32)

# WRONG: Reusing IV with the same key
iv = os.urandom(12)
plaintext1 = b"Secret message 1"
plaintext2 = b"Secret message 2"

cipher1 = Cipher(algorithms.AES(key), modes.GCM(iv), default_backend())
encryptor1 = cipher1.encryptor()
ciphertext1 = encryptor1.update(plaintext1) + encryptor1.finalize()

cipher2 = Cipher(algorithms.AES(key), modes.GCM(iv), default_backend())
# WRONG: Same IV with same key—breaks security!
encryptor2 = cipher2.encryptor()
ciphertext2 = encryptor2.update(plaintext2) + encryptor2.finalize()

# CORRECT: Generate a new IV for each encryption
iv1 = os.urandom(12)
cipher1 = Cipher(algorithms.AES(key), modes.GCM(iv1), default_backend())
encryptor1 = cipher1.encryptor()
ciphertext1 = encryptor1.update(plaintext1) + encryptor1.finalize()

iv2 = os.urandom(12) # New IV
cipher2 = Cipher(algorithms.AES(key), modes.GCM(iv2), default_backend())
encryptor2 = cipher2.encryptor()
ciphertext2 = encryptor2.update(plaintext2) + encryptor2.finalize()

Fix: Always generate a new IV/nonce for each encryption operation. Store the IV alongside the ciphertext; it's not secret, only the key is.

Pitfall 3: Ignoring Hash Collisions and Using MD5/SHA1

MD5 and SHA-1 have known collision vulnerabilities. An attacker can craft two different inputs that produce the same hash, defeating integrity checks and password verification.

import hashlib

# WRONG: Using deprecated hash functions
password = "user_password"
md5_hash = hashlib.md5(password.encode()).hexdigest() # BROKEN
sha1_hash = hashlib.sha1(password.encode()).hexdigest() # BROKEN

# CORRECT: Use SHA-256 or SHA-3 for hashing
sha256_hash = hashlib.sha256(password.encode()).hexdigest()
sha3_hash = hashlib.sha3_256(password.encode()).hexdigest()

# CORRECT: For passwords, use bcrypt or Argon2 (not raw SHA-256)
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())

Fix: Replace MD5 and SHA-1 with SHA-256, SHA-3, or dedicated password hashers (bcrypt, Argon2). Do not use raw SHA-256 for passwords; it's too fast and vulnerable to brute force.

Pitfall 4: Hardcoding Secrets in Source Code

Hardcoded secrets are exposed in Git history, environment variables, logs, and memory dumps. Once committed, they are untrustworthy forever.

# WRONG: Hardcoded API key and encryption key
API_KEY = "sk-1234567890abcdefghijklmnop"
ENCRYPTION_KEY = b"0123456789ABCDEF0123456789ABCDEF"

def call_api(endpoint):
headers = {"Authorization": f"Bearer {API_KEY}"}
# ... API call

# CORRECT: Load secrets from environment or secrets manager
import os
from cryptography.fernet import Fernet

api_key = os.getenv("API_KEY")
if not api_key:
raise ValueError("API_KEY environment variable not set")

encryption_key_b64 = os.getenv("ENCRYPTION_KEY")
if not encryption_key_b64:
raise ValueError("ENCRYPTION_KEY environment variable not set")

encryption_key = encryption_key_b64.encode()

def call_api(endpoint):
headers = {"Authorization": f"Bearer {api_key}"}
# ... API call

# OR: Load from secrets manager (production)
import boto3

def get_secret(name):
client = boto3.client("secretsmanager")
return client.get_secret_value(SecretId=name)["SecretString"]

api_key = get_secret("prod/api-key")
encryption_key = get_secret("prod/encryption-key").encode()

Fix: Never commit secrets to source control. Use environment variables for development/CI, and secrets management systems (AWS Secrets Manager, Vault) for production.

Pitfall 5: Not Validating TLS Certificates

Disabling certificate verification in TLS connections removes the security benefit of encryption and allows man-in-the-middle attacks.

import requests

# WRONG: Disables certificate verification
response = requests.get("https://api.example.com", verify=False)

# WRONG: Ignores self-signed certificate errors
response = requests.get(
"https://self-signed.example.com",
verify=False
)

# CORRECT: Verify using system CA store (default)
response = requests.get("https://api.example.com")

# CORRECT: Verify using a custom CA bundle (for internal CAs)
response = requests.get(
"https://internal.example.com",
verify="/path/to/custom-ca-bundle.crt"
)

Fix: Always verify TLS certificates against a trusted CA. Only disable verification during development with explicit intent and justification.

Pitfall 6: Not Authenticating Encrypted Data (CBC Without HMAC)

CBC mode (Cipher Block Chaining) encrypts data but provides no authentication. An attacker can modify the ciphertext and the decryption will succeed—the modification is not detected. Always use authenticated encryption (AEAD modes like GCM).

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import hmac
import hashlib

key = os.urandom(32)
iv = os.urandom(16)

plaintext = b"Sensitive data"

# WRONG: CBC mode without authentication
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
# An attacker can flip bits in ciphertext; decryption succeeds but data is corrupted

# CORRECT: GCM mode (authenticated encryption)
cipher = Cipher(algorithms.AES(key), modes.GCM(os.urandom(12)), default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
tag = encryptor.tag
# Tampering is detected; decryption raises an exception if tag is invalid

# ALTERNATIVE: CBC + HMAC (if GCM is unavailable)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), default_backend())
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()

# Compute HMAC over the ciphertext
h = hmac.new(key, ciphertext, hashlib.sha256)
tag = h.digest()
# Send IV + ciphertext + tag; verify tag before decrypting

Fix: Use AEAD modes (GCM preferred; CBC + HMAC as fallback). Always authenticate encrypted data.

Pitfall 7: Using Random for Password Generation

random.choice() should not be used to generate passwords or cryptographic tokens. It's predictable and weak.

import random
import string

# WRONG: Weak password generation
password = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(16))

# CORRECT: Use secrets for cryptographic randomness
import secrets

password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))

# ALTERNATIVE: Generate a base64-encoded random token
token = secrets.token_urlsafe(24) # 32 random bytes, base64-encoded

Fix: Use secrets or os.urandom() for password and token generation. Never use random.

Pitfall 8: Trusting User Input Without Validation

User input should never be trusted in cryptographic operations. Validate lengths, formats, and ranges before using input in cryptographic functions.

from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

# WRONG: No validation of user input
user_message = request.json["message"] # User-provided
signature = request.json["signature"] # User-provided

public_key.verify(signature, user_message, ...) # May crash or leak info

# CORRECT: Validate input before use
user_message = request.json.get("message", "").strip()
if not user_message or len(user_message) > 10000:
raise ValueError("Invalid message length")

user_signature_hex = request.json.get("signature", "").strip()
try:
signature = bytes.fromhex(user_signature_hex)
except ValueError:
raise ValueError("Signature must be hex-encoded")

if len(signature) != 256: # RSA-2048 signature size
raise ValueError("Invalid signature size")

try:
public_key.verify(
signature,
user_message.encode(),
padding.PSS(...),
hashes.SHA256()
)
except Exception:
raise ValueError("Signature verification failed")

Fix: Validate all user input before using it in cryptographic operations. Check lengths, formats, and ranges.

Pitfall 9: Mixing Up Encryption and Signing Keys

Using the same key for both encryption and signing increases attack surface. Best practice: separate keys for separate purposes.

from cryptography.hazmat.primitives.asymmetric import rsa

# WRONG: One key for both purposes
multi_purpose_key = rsa.generate_private_key(65537, 2048)

# Use for encryption
ciphertext = multi_purpose_key.public_key().encrypt(...)

# Use for signing (same key)
signature = multi_purpose_key.sign(...)

# CORRECT: Separate keys
encryption_key = rsa.generate_private_key(65537, 2048)
signing_key = rsa.generate_private_key(65537, 2048)

ciphertext = encryption_key.public_key().encrypt(...)
signature = signing_key.sign(...)

Using the same key for multiple purposes increases risk if the key is compromised. Separation is a defense-in-depth principle.

Fix: Use separate keys for encryption, signing, and authentication. Rotate each independently.

Pitfall 10: Not Using Salts with Hashing

Hashing without salts enables rainbow-table attacks. Always use salts (random values) and let libraries handle the details.

import hashlib
import os

password = "user_password"

# WRONG: No salt (rainbow-table vulnerable)
hashed = hashlib.sha256(password.encode()).hexdigest()

# CORRECT: Use bcrypt (includes salt internally)
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())

# CORRECT: Manual salt (if implementing custom hashing)
salt = os.urandom(16)
salted = salt + password.encode()
hashed = hashlib.sha256(salted).hexdigest()
# Store: salt + hashed (both together)

Fix: Use bcrypt or Argon2 for passwords. Both handle salts automatically and are resistant to brute-force attacks.

Quick Reference: Do's and Don'ts

ActionDODON'T
Random numbersos.urandom(), secretsrandom, uuid
Hash functionsSHA-256, SHA-3, bcrypt, Argon2MD5, SHA-1
Symmetric encryptionAES-GCM (Fernet)AES-ECB, AES-CBC without HMAC
Key storageSecrets manager, environmentSource code, plaintext files
TLS certificatesVerify with trusted CAverify=False, self-signed (prod)
IV/nonce reuseNew IV per encryptionReuse with same key
PaddingOAEP (RSA), PKCS7 (symmetric)No padding, raw RSA

Key Takeaways

  • Use only vetted, standard libraries: cryptography, bcrypt, hashlib, never custom implementations.
  • Always use cryptographically secure randomness (os.urandom(), secrets) for keys, IVs, salts, and tokens.
  • Never reuse IVs in GCM mode; always authenticate encrypted data (use AEAD).
  • Hardcoded secrets are permanent vulnerabilities; load from environment or secrets managers.
  • Validate all user input before using in cryptographic operations.
  • Separate keys for separate purposes; rotate keys periodically.

Frequently Asked Questions

Is it safe to store the IV alongside the ciphertext?

Yes. The IV is not secret; it just needs to be unique per encryption with the same key. Store IV + ciphertext + tag together.

Can I use AES-128 instead of AES-256?

Yes. AES-128 is still secure and faster. Use AES-128 for high-throughput scenarios; AES-256 for long-term storage and highly sensitive data.

What's the difference between secrets and os.urandom()?

Both are cryptographically secure. secrets is a higher-level interface designed specifically for generating tokens and secrets; os.urandom() is the lower-level OS entropy source. For most purposes, use secrets.

Should I hash passwords before sending over HTTPS?

No. HTTPS encryption already protects the password in transit. Client-side hashing adds no benefit and complicates verification. Transmit plaintext over HTTPS; hash server-side.

How do I know if a cryptographic library is trustworthy?

Check: is it maintained by security experts? (PyCA Cryptography is maintained by security professionals.) Does it have security audits? Is it used by major projects (OpenSSL, OpenSSH, TLS libraries)? Does it follow established standards (NIST, RFC)? Avoid small, unmaintained libraries.

Further Reading