Python Cryptography: Hashing, Signing and Encryption

The cryptography library is Python's production-grade cryptographic toolkit — it wraps OpenSSL and provides both a high-level recipe layer (Fernet symmetric encryption, PBKDF2 password hashing) and a low-level hazmat layer for AES-GCM, RSA, ECDSA, X.509, and TLS. This guide covers the most important real-world use cases: encrypting sensitive data at rest, signing API payloads, hashing passwords, and generating secure tokens.

Installation and Secure Randomness

pip install cryptography bcrypt argon2-cffi
import os
import secrets
import base64

# Always use secrets or os.urandom for cryptographic randomness
# Never use random.random() or random.randint() for security purposes

# Generate a 256-bit (32-byte) random key
key_bytes = os.urandom(32)
key_hex = key_bytes.hex()
key_b64 = base64.urlsafe_b64encode(key_bytes).decode()

# Generate secure tokens
api_token = secrets.token_hex(32)         # 64-char hex string
url_token = secrets.token_urlsafe(32)     # URL-safe base64, 43 chars
pin = secrets.randbelow(1_000_000)        # Secure random int in [0, 1000000)

# Timing-safe comparison (prevents timing attacks)
is_valid = secrets.compare_digest(
    "provided_token".encode(),
    "expected_token".encode(),
)
Security rule: Always use secrets.compare_digest() or hmac.compare_digest() when comparing tokens or hashes. Regular == comparison leaks timing information that allows attackers to guess tokens byte-by-byte.

Cryptographic Hashing and HMAC

SHA-256 and SHA-3 hashes are one-way fingerprints — use them for content integrity verification, deduplication, and cache keys. HMAC (Hash-based Message Authentication Code) adds a secret key to prevent forged hashes — use it for webhook signature verification and signed tokens.

import hashlib
import hmac
import os


# SHA-256 hash of content (for integrity checks, deduplication)
def sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


# HMAC-SHA256 — message authentication with a shared secret
SECRET_KEY = os.environ.get("HMAC_SECRET", os.urandom(32).hex()).encode()


def sign_payload(payload: bytes) -> str:
    return hmac.new(SECRET_KEY, payload, hashlib.sha256).hexdigest()


def verify_payload(payload: bytes, signature: str) -> bool:
    expected = sign_payload(payload)
    return hmac.compare_digest(expected, signature)


# Webhook signature verification (GitHub / Stripe pattern)
def verify_github_webhook(payload: bytes, signature_header: str) -> bool:
    """Verify GitHub webhook X-Hub-Signature-256 header."""
    if not signature_header.startswith("sha256="):
        return False
    provided_sig = signature_header[7:]  # strip "sha256="
    expected_sig = hmac.new(SECRET_KEY, payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected_sig, provided_sig)


# Usage
data = b'{"event": "push", "repo": "techoral"}'
sig = sign_payload(data)
print(verify_payload(data, sig))           # True
print(verify_payload(b"tampered", sig))    # False

Password Hashing with bcrypt and Argon2

Never store passwords as plain text or as reversible encryption. Use a slow password hashing algorithm: bcrypt (battle-tested) or Argon2 (Argon2id, the Password Hashing Competition winner). Both are intentionally slow and include a random salt to prevent rainbow table attacks.

import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError


# bcrypt — widely supported, battle-tested
def hash_password_bcrypt(password: str) -> str:
    salt = bcrypt.gensalt(rounds=12)  # rounds=12 is ~250ms on modern hardware
    hashed = bcrypt.hashpw(password.encode(), salt)
    return hashed.decode()


def verify_password_bcrypt(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode(), hashed.encode())


# Argon2id — recommended for new systems (winner of PHC)
ph = PasswordHasher(
    time_cost=3,        # number of iterations
    memory_cost=65536,  # 64 MiB memory
    parallelism=4,      # threads
    hash_len=32,
    salt_len=16,
)


def hash_password_argon2(password: str) -> str:
    return ph.hash(password)


def verify_password_argon2(password: str, hashed: str) -> bool:
    try:
        ph.verify(hashed, password)
        # Check if parameters need upgrading
        if ph.check_needs_rehash(hashed):
            return True  # signal caller to rehash
        return True
    except VerifyMismatchError:
        return False


# Example usage in a FastAPI auth endpoint
def authenticate_user(password: str, stored_hash: str) -> bool:
    return verify_password_argon2(password, stored_hash)


# bcrypt example
hashed = hash_password_bcrypt("my-secure-password")
print(verify_password_bcrypt("my-secure-password", hashed))   # True
print(verify_password_bcrypt("wrong-password", hashed))       # False

Symmetric Encryption with Fernet

Fernet is the cryptography library's high-level symmetric encryption recipe. It uses AES-128-CBC with HMAC-SHA256 for authenticated encryption — it guarantees both confidentiality and integrity, and includes a timestamp for optional TTL-based expiry. Use Fernet when you need simple encrypt/decrypt without managing nonces manually.

from cryptography.fernet import Fernet, MultiFernet
import os


# Generate and store a key (keep this secret — store in environment variable or secrets manager)
key = Fernet.generate_key()             # returns URL-safe base64-encoded 32-byte key
f = Fernet(key)

# Encrypt
plaintext = b"sensitive customer data: CC 4111-1111-1111-1111"
token = f.encrypt(plaintext)
print(token)  # b'gAAAAAAB...' — URL-safe base64

# Decrypt
decrypted = f.decrypt(token)
assert decrypted == plaintext

# TTL-based expiry — reject tokens older than 60 seconds
import time
token = f.encrypt(b"time-sensitive payload")
time.sleep(1)
try:
    data = f.decrypt(token, ttl=60)   # raises InvalidToken if older than 60s
except Exception:
    print("Token expired")


# Key rotation with MultiFernet
old_key = Fernet.generate_key()
new_key = Fernet.generate_key()

# MultiFernet tries keys in order — encrypt with newest, decrypt with any
mf = MultiFernet([Fernet(new_key), Fernet(old_key)])

# Re-encrypt existing tokens to the new key
old_token = Fernet(old_key).encrypt(b"old data")
rotated_token = mf.rotate(old_token)  # now encrypted with new_key

# Real-world usage: encrypt PII in database
import base64

SECRET_KEY = os.environ.get("ENCRYPTION_KEY", Fernet.generate_key().decode()).encode()

def encrypt_field(value: str) -> str:
    f = Fernet(SECRET_KEY)
    return f.encrypt(value.encode()).decode()

def decrypt_field(encrypted: str) -> str:
    f = Fernet(SECRET_KEY)
    return f.decrypt(encrypted.encode()).decode()

AES-GCM for Authenticated Encryption

AES-256-GCM is the industry standard for symmetric authenticated encryption. Unlike AES-CBC, GCM produces an authentication tag that detects any tampering with the ciphertext. Use it when you need interoperability with other systems or need to control the nonce.

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


# Generate a 256-bit (32-byte) key
key = os.urandom(32)
aesgcm = AESGCM(key)


def encrypt_aes_gcm(plaintext: bytes, associated_data: bytes = b"") -> tuple[bytes, bytes]:
    """Returns (nonce, ciphertext+tag). Store nonce alongside ciphertext."""
    nonce = os.urandom(12)  # 96-bit nonce — never reuse with the same key
    ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
    return nonce, ciphertext


def decrypt_aes_gcm(nonce: bytes, ciphertext: bytes, associated_data: bytes = b"") -> bytes:
    """Decrypt and verify. Raises InvalidTag if tampered."""
    return aesgcm.decrypt(nonce, ciphertext, associated_data)


# Example: encrypt a database column value
plaintext = b"patient: John Doe, DOB: 1990-05-15, SSN: 123-45-6789"
aad = b"user_id:12345"  # associated data is authenticated but not encrypted

nonce, ciphertext = encrypt_aes_gcm(plaintext, aad)

# Store: hex(nonce) + ":" + hex(ciphertext) in the database column
stored = nonce.hex() + ":" + ciphertext.hex()

# Decrypt
parts = stored.split(":")
recovered_nonce = bytes.fromhex(parts[0])
recovered_ct = bytes.fromhex(parts[1])
recovered = decrypt_aes_gcm(recovered_nonce, recovered_ct, aad)
assert recovered == plaintext

Asymmetric Encryption with RSA

RSA is used to encrypt small data (usually symmetric keys) or to verify identities. For encrypting data, use RSA-OAEP padding. For signing, use RSA-PSS. Never use RSA with PKCS1v15 padding for new systems — it is vulnerable to padding oracle attacks.

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()

# Serialize keys for storage/transmission
pem_private = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.BestAvailableEncryption(b"passphrase"),
)

pem_public = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

# Encrypt with public key (recipient decrypts with private key)
message = b"AES key: " + os.urandom(32)
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

Digital Signatures with ECDSA

ECDSA (Elliptic Curve Digital Signature Algorithm) provides the same security as RSA with much smaller keys. P-256 (secp256r1) is the standard curve used in TLS, JWT (ES256), and code signing. Use Ed25519 for even better performance and simpler API — it is available in the cryptography library and is recommended for new systems.

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.exceptions import InvalidSignature


# Ed25519 — modern, fast, simple (recommended for new systems)
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()

message = b'{"action":"transfer","amount":1000,"to":"alice@example.com"}'
signature = private_key.sign(message)

try:
    public_key.verify(signature, message)
    print("Signature valid")
except InvalidSignature:
    print("Signature INVALID — message tampered!")


# ECDSA P-256 — compatible with existing JWS/JWT ES256 infrastructure
ec_private = ec.generate_private_key(ec.SECP256R1())
ec_public = ec_private.public_key()

signature_ecdsa = ec_private.sign(message, ec.ECDSA(hashes.SHA256()))

try:
    ec_public.verify(signature_ecdsa, message, ec.ECDSA(hashes.SHA256()))
    print("ECDSA signature valid")
except InvalidSignature:
    print("ECDSA signature INVALID")


# Sign an API response and attach signature as a header
import base64

def sign_response(body: bytes) -> str:
    sig = private_key.sign(body)
    return base64.urlsafe_b64encode(sig).decode()

def verify_response(body: bytes, sig_header: str) -> bool:
    try:
        sig = base64.urlsafe_b64decode(sig_header)
        public_key.verify(sig, body)
        return True
    except (InvalidSignature, Exception):
        return False

Frequently Asked Questions

Which encryption should I use for data at rest?
For simple use cases, use Fernet — it handles nonce generation and authentication automatically. For performance-critical or interoperable use cases, use AES-256-GCM directly. Never use AES-CBC without an authentication tag (MAC) — it doesn't detect tampering.
bcrypt vs Argon2 for password hashing?
Both are secure. Argon2id is the modern recommendation (winner of the 2015 Password Hashing Competition) and is harder to attack with GPUs due to its memory-hardness. Use Argon2id for new systems and bcrypt for existing systems where bcrypt is already established.
What is the difference between hashing and encryption?
Hashing is one-way — you cannot recover the original data. Encryption is reversible with the correct key. Use hashing for passwords and content fingerprints. Use encryption when you need to recover the original data (PII storage, API keys in a database).