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.

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.
Rule of thumb: If a value will be used for authentication, authorization, session management, or any security-sensitive purpose, 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)
What to log: Authentication successes and failures, authorization denials, privilege escalation, account lockouts, password changes, API key rotations, and any admin action. Never log the password, token value, or full credit card number — log the last 4 digits or a masked version at most.