Python Secrets and Security: Cryptography Best Practices

Security in Python applications requires more than HTTPS and firewalls. You need cryptographically secure random values, proper password hashing (not just MD5), authenticated encryption for stored secrets, and safe key management. This guide covers the secrets module, the cryptography library, password hashing with bcrypt and Argon2, symmetric and asymmetric encryption, HMAC, and secrets management patterns.

The secrets Module

The standard library's secrets module (Python 3.6+) generates cryptographically secure random values using the OS's random number generator. Always use secrets instead of random for security-sensitive values like tokens, passwords, and nonces. random is predictable and not suitable for security purposes.

import secrets
import string

# Generate a URL-safe token (API keys, reset tokens, session IDs)
token = secrets.token_urlsafe(32)      # 43 chars, 256 bits of entropy
hex_token = secrets.token_hex(32)      # 64-char hex string
bytes_token = secrets.token_bytes(32)  # 32 raw bytes

print(token)  # e.g., "XqZJ0a3mTLiB8v4YgN9_kd2oEpFy6rKw..."

# Generate a secure random password
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = "".join(secrets.choice(alphabet) for _ in range(16))
print(password)  # e.g., "kR#3mP@9qW!5nX&2"

# Ensure at least one of each character class
while True:
    password = "".join(secrets.choice(alphabet) for _ in range(16))
    if (any(c.isupper() for c in password) and
        any(c.islower() for c in password) and
        any(c.isdigit() for c in password) and
        any(c in "!@#$%^&*" for c in password)):
        break

# Constant-time comparison (prevent timing attacks)
def secure_compare(a: str, b: str) -> bool:
    return secrets.compare_digest(a.encode(), b.encode())

# Generate a CSRF token
csrf_token = secrets.token_hex(16)
# Store in session, validate on POST
Never use random for security: random.randint(), random.choice(), and uuid.uuid4() are NOT cryptographically secure on some platforms. Use secrets for tokens, passwords, nonces, and salts.

Password Hashing: bcrypt and Argon2

Passwords must be hashed with a slow, salted, one-way function. MD5, SHA-256, and SHA-512 are fast hashing algorithms — fast means attackers can try billions of guesses per second. Use bcrypt or Argon2 which are intentionally slow and include work factors to stay secure as hardware improves.

import bcrypt

# Hash a password (never store plain text)
def hash_password(plain: str) -> str:
    salt = bcrypt.gensalt(rounds=12)  # 2^12 = 4096 iterations
    hashed = bcrypt.hashpw(plain.encode(), salt)
    return hashed.decode()

# Verify a password
def verify_password(plain: str, hashed: str) -> bool:
    return bcrypt.checkpw(plain.encode(), hashed.encode())

# Usage
stored_hash = hash_password("my-secure-password")
print(verify_password("my-secure-password", stored_hash))   # True
print(verify_password("wrong-password", stored_hash))       # False

# Argon2 — stronger than bcrypt, recommended for new systems
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher(
    time_cost=3,      # iterations
    memory_cost=64 * 1024,  # 64 MB
    parallelism=1,
)

hashed = ph.hash("my-secure-password")
try:
    ph.verify(hashed, "my-secure-password")  # raises on mismatch
    print("Password correct")
    if ph.check_needs_rehash(hashed):
        hashed = ph.hash("my-secure-password")  # rehash if params changed
except VerifyMismatchError:
    print("Wrong password")

Symmetric Encryption with Fernet

Fernet (from the cryptography library) provides authenticated symmetric encryption using AES-128-CBC + HMAC-SHA256. It handles key generation, IV generation, padding, and authentication automatically. Use it to encrypt data at rest like API keys stored in a database.

from cryptography.fernet import Fernet, MultiFernet
import base64
import os

# Generate and store a key (once, securely)
key = Fernet.generate_key()  # 32 random bytes, base64-encoded
print(key)  # b'...' — store in a secrets manager, NOT in code

# Encrypt
fernet = Fernet(key)
plaintext = b"sensitive data: api_key=sk-abc123xyz"
ciphertext = fernet.encrypt(plaintext)
print(ciphertext)  # b'gAAAAA...' — safe to store in DB

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

# Fernet with TTL — automatically rejects tokens older than N seconds
ciphertext = fernet.encrypt(b"short-lived token")
try:
    data = fernet.decrypt(ciphertext, ttl=60)  # valid for 60 seconds only
except Exception as e:
    print(f"Token expired or invalid: {e}")

# Key rotation with MultiFernet
old_key = Fernet(os.environ["OLD_ENCRYPTION_KEY"])
new_key = Fernet(os.environ["NEW_ENCRYPTION_KEY"])
mf = MultiFernet([new_key, old_key])

# Encrypt with new key, decrypt either key
encrypted = mf.encrypt(b"data")
decrypted = mf.decrypt(encrypted)  # works with both keys

# Rotate existing ciphertexts to new key
rotated = mf.rotate(old_ciphertext)  # re-encrypts with new key

AES-GCM: Authenticated Encryption

For more control over encryption parameters, use AES-GCM directly. GCM mode provides both confidentiality and integrity in a single pass — you don't need a separate HMAC.

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

# Generate a 256-bit key
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)

# Encrypt with additional authenticated data (AAD)
nonce = os.urandom(12)  # 96-bit nonce — MUST be unique per encryption
plaintext = b"credit card: 4111-1111-1111-1111"
aad = b"user_id:12345"  # authenticated but NOT encrypted

ciphertext = aesgcm.encrypt(nonce, plaintext, aad)

# Store: nonce + ciphertext (nonce is not secret)
stored = nonce + ciphertext

# Decrypt
nonce_out = stored[:12]
ct_out = stored[12:]
decrypted = aesgcm.decrypt(nonce_out, ct_out, aad)
assert decrypted == plaintext

# Tampering with AAD causes decryption to fail
try:
    aesgcm.decrypt(nonce_out, ct_out, b"wrong_aad")
except Exception:
    print("Authentication failed — data was tampered with")

HMAC: Message Authentication

HMAC (Hash-based Message Authentication Code) proves that a message was created by someone who knows the secret key and has not been tampered with. Use it for webhook signature verification, cookie signing, and API request authentication.

import hmac
import hashlib
import time

SECRET_KEY = b"your-webhook-secret"

def sign_payload(payload: bytes) -> str:
    """Generate HMAC-SHA256 signature for a webhook payload."""
    sig = hmac.new(SECRET_KEY, payload, hashlib.sha256).hexdigest()
    return f"sha256={sig}"

def verify_signature(payload: bytes, signature: str, max_age_seconds: int = 300) -> bool:
    """Verify webhook signature — reject replays older than max_age_seconds."""
    expected = sign_payload(payload)
    # Constant-time comparison prevents timing attacks
    return hmac.compare_digest(expected, signature)

# GitHub webhook verification pattern
def verify_github_webhook(request_body: bytes, x_hub_signature_256: str) -> bool:
    expected = "sha256=" + hmac.new(
        SECRET_KEY, request_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, x_hub_signature_256)

# Signed cookie value (stateless session token)
def make_signed_value(value: str) -> str:
    timestamp = str(int(time.time()))
    payload = f"{timestamp}:{value}"
    sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
    return f"{payload}:{sig}"

def verify_signed_value(signed: str, max_age: int = 86400) -> str | None:
    parts = signed.rsplit(":", 2)
    if len(parts) != 3:
        return None
    timestamp, value, sig = parts
    payload = f"{timestamp}:{value}"
    expected_sig = hmac.new(SECRET_KEY, payload.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected_sig, sig):
        return None
    if int(time.time()) - int(timestamp) > max_age:
        return None
    return value

RSA Asymmetric Encryption

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization

# Generate RSA key pair
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# Encrypt with public key (anyone can encrypt, only private key holder can decrypt)
message = b"secret message for the recipient"
ciphertext = public_key.encrypt(
    message,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    ),
)

# Decrypt with private key
plaintext = private_key.decrypt(
    ciphertext,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    ),
)
assert plaintext == message

# Sign with private key
signature = private_key.sign(
    b"document to sign",
    padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
    hashes.SHA256(),
)

# Verify with public key
from cryptography.exceptions import InvalidSignature
try:
    public_key.verify(
        signature,
        b"document to sign",
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
        hashes.SHA256(),
    )
    print("Signature valid")
except InvalidSignature:
    print("Signature invalid!")

Key and Secrets Management

Cryptography is only as strong as its key management. Keys in source code or environment variables are better than nothing but not ideal. Use a dedicated secrets manager in production.

import os
import boto3  # AWS Secrets Manager
from functools import lru_cache

# BAD: key in source code
ENCRYPTION_KEY = b"hardcoded-key-never-do-this!!"

# BETTER: environment variable
ENCRYPTION_KEY = os.environ["ENCRYPTION_KEY"].encode()

# BEST: AWS Secrets Manager with caching
@lru_cache(maxsize=None)
def get_secret(secret_name: str) -> str:
    client = boto3.client("secretsmanager", region_name="us-east-1")
    response = client.get_secret_value(SecretId=secret_name)
    return response["SecretString"]

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

# Key derivation — derive multiple keys from one master key
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

master_key = secrets.token_bytes(32)

def derive_key(master: bytes, purpose: str, length: int = 32) -> bytes:
    """Derive a purpose-specific key from a master key."""
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=length,
        salt=None,
        info=purpose.encode(),
    )
    return hkdf.derive(master)

db_encryption_key = derive_key(master_key, "database-encryption")
api_signing_key = derive_key(master_key, "api-request-signing")

Frequently Asked Questions

What is the difference between hashing and encryption?
Hashing is one-way — you cannot recover the original from the hash. Use it for passwords. Encryption is two-way — you can decrypt with the key. Use it for data you need to read back (stored API keys, PII). Never encrypt passwords — always hash them.
Is base64 encoding secure?
No. Base64 is an encoding, not encryption. Anyone can decode it. Never rely on base64 for confidentiality — it is only for encoding binary data as text. Always encrypt first, then base64-encode the ciphertext if needed.
How do I securely delete secrets from memory?
Python's garbage collector doesn't guarantee when objects are freed, and strings are immutable (can't zero out). Use bytearray instead of bytes for secrets you want to wipe: key = bytearray(secret); key[:] = b'\x00' * len(key). For maximum security, use OS-level secure memory (mlock) via the pyca/cryptography library's hazmat layer.