Python Secrets and Security: Cryptography Best Practices
Security in Python applications requires choosing the right tools and avoiding common pitfalls — using random instead of secrets for token generation, storing plain-text passwords, or building custom encryption instead of using established libraries. This guide covers the Python standard library's secrets module, password hashing with bcrypt and Argon2, symmetric encryption with Fernet, message authentication with HMAC, and the subtle timing attack vulnerabilities that affect naive string comparison.
Table of Contents
The secrets Module
Python's secrets module (added in 3.6) provides cryptographically strong random numbers suitable for generating tokens, passwords, API keys, and session identifiers. It uses the OS's random number generator (/dev/urandom on Linux, CryptGenRandom on Windows) rather than the Mersenne Twister used by the random module, which is entirely predictable given a few outputs.
import secrets
import string
# Generate a URL-safe token (for API keys, session IDs, password reset links)
token = secrets.token_urlsafe(32) # 32 bytes = 43 characters, base64url encoded
print(token) # e.g. "kKHjMzAB8D_y1wACP2Ub4fxXFmMhLpz_UDDaUQ2EJeY"
# Generate a hex token (for email verification, CSRF tokens)
hex_token = secrets.token_hex(16) # 16 bytes = 32 hex characters
print(hex_token) # e.g. "a3f4b2c1d0e9f8a7b6c5d4e3f2a1b0c9"
# Generate a random bytes token
raw_token = secrets.token_bytes(32)
# Generate a secure random password
alphabet = string.ascii_letters + string.digits + string.punctuation
password = "".join(secrets.choice(alphabet) for _ in range(16))
# Or use a pronounceable word-based passphrase (from a wordlist)
import random # only for index, still use secrets for the selection
WORDS = ["correct", "horse", "battery", "staple", "purple", "cloud"]
passphrase = " ".join(secrets.choice(WORDS) for _ in range(4))
print(passphrase) # e.g. "battery purple horse staple"
# OTP-style numeric code
otp_code = "".join(str(secrets.randbelow(10)) for _ in range(6))
print(otp_code) # e.g. "473821"
# WRONG — never use random for security tokens
import random
bad_token = random.randbytes(32) # predictable! Do not use.
secrets. Use the random module only for simulations, games, and non-security randomness.
Password Hashing: bcrypt and Argon2
Passwords must never be stored in plain text or hashed with fast hashing algorithms like MD5 or SHA-256. Fast hashes allow attackers to crack millions of passwords per second with a GPU. bcrypt and Argon2 are slow-by-design password hashing functions with a configurable work factor that can be tuned to keep brute-force attacks computationally expensive even as hardware improves. Argon2 is the current OWASP recommendation and winner of the Password Hashing Competition.
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
# --- bcrypt ---
def hash_password_bcrypt(plain: str) -> bytes:
"""Hash a password with bcrypt (cost factor 12)."""
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=12))
def verify_password_bcrypt(plain: str, hashed: bytes) -> bool:
"""Timing-safe comparison built into bcrypt.checkpw."""
return bcrypt.checkpw(plain.encode(), hashed)
# Usage
hashed = hash_password_bcrypt("my-secure-password")
print(hashed) # b"$2b$12$..."
print(verify_password_bcrypt("my-secure-password", hashed)) # True
print(verify_password_bcrypt("wrong-password", hashed)) # False
# --- Argon2id (recommended) ---
ph = PasswordHasher(
time_cost=3, # number of iterations
memory_cost=65536, # 64 MB
parallelism=4, # 4 threads
hash_len=32,
salt_len=16,
)
def hash_password_argon2(plain: str) -> str:
return ph.hash(plain)
def verify_password_argon2(plain: str, hashed: str) -> bool:
try:
ph.verify(hashed, plain)
# Rehash if parameters have changed
if ph.check_needs_rehash(hashed):
new_hash = ph.hash(plain)
# save new_hash to database
return True
except (VerifyMismatchError, VerificationError):
return False
hashed = hash_password_argon2("my-secure-password")
print(hashed) # "$argon2id$v=19$m=65536,t=3,p=4$..."
Symmetric Encryption with Fernet
Fernet is a symmetric authenticated encryption scheme from the cryptography library. It combines AES-128-CBC for confidentiality with HMAC-SHA256 for integrity, and handles IV generation, padding, and authentication automatically. It's the right choice for encrypting data at rest — configuration secrets, personally identifiable information, or any value that needs to be decrypted later.
from cryptography.fernet import Fernet, MultiFernet
import base64
# Generate a key (store this securely — in AWS Secrets Manager, Vault, etc.)
key = Fernet.generate_key() # 32 random bytes, URL-safe base64 encoded
print(key) # b"AAAA...="
f = Fernet(key)
# Encrypt
plaintext = b"sensitive user data"
token = f.encrypt(plaintext)
print(token) # b"gAAAAA...=" — includes IV, ciphertext, HMAC
# Decrypt (raises InvalidToken if tampered or wrong key)
from cryptography.fernet import InvalidToken
try:
decrypted = f.decrypt(token)
print(decrypted) # b"sensitive user data"
except InvalidToken:
print("Decryption failed — token is invalid or key is wrong")
# Decrypt with TTL (reject tokens older than 300 seconds)
try:
decrypted = f.decrypt(token, ttl=300)
except InvalidToken:
print("Token expired")
# Key rotation with MultiFernet
old_key = Fernet(b"old-key-32-bytes-url-safe-encoded=")
new_key = Fernet(Fernet.generate_key())
mf = MultiFernet([new_key, old_key]) # tries new_key first, falls back to old_key
# Rotate all existing tokens to the new key
old_token = old_key.encrypt(b"data")
rotated_token = mf.rotate(old_token) # now encrypted with new_key
# Derive a key from a passphrase
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
import os
def derive_key_from_password(password: str, salt: bytes = None) -> tuple[bytes, bytes]:
if salt is None:
salt = os.urandom(16)
kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
return key, salt
HMAC Message Authentication
HMAC (Hash-based Message Authentication Code) proves that a message came from someone holding a shared secret key and that it has not been tampered with. It is used for webhook signature verification, signed cookies, and API request signing. Python's hmac module provides a constant-time implementation that prevents timing attacks.
import hmac
import hashlib
import json
SECRET_KEY = b"my-webhook-secret-key"
def sign_payload(payload: dict, secret: bytes = SECRET_KEY) -> str:
"""Create an HMAC-SHA256 signature for a payload."""
body = json.dumps(payload, sort_keys=True).encode()
signature = hmac.new(secret, body, hashlib.sha256).hexdigest()
return f"sha256={signature}"
def verify_webhook(payload: dict, signature: str, secret: bytes = SECRET_KEY) -> bool:
"""Verify an incoming webhook signature (GitHub-style)."""
expected = sign_payload(payload, secret)
# hmac.compare_digest is timing-safe
return hmac.compare_digest(expected, signature)
# Example: verifying a GitHub webhook
def verify_github_webhook(body: bytes, signature_header: str, secret: str) -> bool:
mac = hmac.new(secret.encode(), body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, signature_header)
# Signed URL tokens (short-lived, tamper-proof)
import time
def create_signed_token(data: str, ttl: int = 3600) -> str:
expires = int(time.time()) + ttl
message = f"{data}:{expires}".encode()
sig = hmac.new(SECRET_KEY, message, hashlib.sha256).hexdigest()
return f"{data}:{expires}:{sig}"
def verify_signed_token(token: str) -> str | None:
parts = token.rsplit(":", 2)
if len(parts) != 3:
return None
data, expires_str, sig = parts
if int(time.time()) > int(expires_str):
return None # expired
message = f"{data}:{expires_str}".encode()
expected = hmac.new(SECRET_KEY, message, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, sig):
return data
return None
Timing-Safe Comparison
Naive string comparison with == is vulnerable to timing attacks: Python's string equality short-circuits on the first differing byte, so an attacker can measure response times to guess secret values one character at a time. Always use hmac.compare_digest() or secrets.compare_digest() when comparing security-sensitive values like API keys, tokens, and HMAC signatures.
import hmac
import secrets
# VULNERABLE — timing attack possible
def verify_api_key_bad(provided: str, expected: str) -> bool:
return provided == expected # short-circuits!
# SAFE — constant time regardless of where strings differ
def verify_api_key_safe(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.encode(), expected.encode())
# secrets.compare_digest is equivalent
def verify_token_safe(provided: str, expected: str) -> bool:
return secrets.compare_digest(provided, expected)
# FastAPI middleware example
from fastapi import Request, HTTPException
API_KEY = secrets.token_urlsafe(32) # load from environment in production
async def verify_api_key_middleware(request: Request):
provided = request.headers.get("X-API-Key", "")
if not hmac.compare_digest(provided, API_KEY):
raise HTTPException(status_code=401, detail="Invalid API key")
Key Derivation Functions
Key Derivation Functions (KDFs) turn low-entropy secrets (passphrases, master keys) into high-entropy cryptographic keys. PBKDF2-HMAC, bcrypt, scrypt, and Argon2 are all KDFs — the difference is that Argon2 and scrypt also require large amounts of memory, making parallel GPU attacks harder. Use KDFs whenever you derive encryption keys from user-supplied input.
import os
import hashlib
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
def derive_key_pbkdf2(password: str, salt: bytes | None = None) -> tuple[bytes, bytes]:
"""Derive a 256-bit AES key from a password using PBKDF2-HMAC-SHA256."""
if salt is None:
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256-bit key
salt=salt,
iterations=600_000, # OWASP 2023 recommendation
)
key = kdf.derive(password.encode())
return key, salt
def verify_derived_key(password: str, stored_key: bytes, salt: bytes) -> bool:
"""Verify a password against a previously derived key."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=600_000,
)
try:
kdf.verify(password.encode(), stored_key)
return True
except Exception:
return False
# Usage — store (key, salt) in database, never the password
password = "user-entered-passphrase"
derived, salt = derive_key_pbkdf2(password)
# ... store derived and salt ...
# Later verification:
is_valid = verify_derived_key(password, derived, salt)
Security Audit Logging
Security events must be logged separately from application logs, with enough detail to reconstruct what happened, who did it, and when. Audit logs should be append-only, tamper-evident, and shipped to an external SIEM as quickly as possible. Never log sensitive values like passwords, tokens, or PII — log identifiers and event types instead.
import logging
import json
from datetime import datetime, timezone
from typing import Any
# Dedicated audit logger — ship to separate handler/file/SIEM
audit_logger = logging.getLogger("audit")
def log_security_event(
event: str,
user_id: str | None,
ip: str | None,
success: bool,
details: dict[str, Any] | None = None,
):
"""Emit a structured security audit log entry."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
"user_id": user_id,
"ip": ip,
"success": success,
"details": details or {},
}
audit_logger.info(json.dumps(entry))
# Usage in a FastAPI route
async def login(email: str, password: str, request: Request):
user = await get_user_by_email(email)
ip = request.client.host
if not user or not verify_password_argon2(password, user.password_hash):
log_security_event("login_failed", None, ip, False, {"email": email})
raise HTTPException(401, "Invalid credentials")
log_security_event("login_success", user.id, ip, True)
return create_access_token(user.id)