cryptography
cryptography is the de facto cryptographic standard library for Python, maintained by the Python Cryptographic Authority (pyca). It has two layers: high-level recipes with safe defaults (Fernet for symmetric encryption) and a low-level layer literally named hazmat with primitives such as AES-GCM, RSA, Ed25519, key derivation functions, HMAC, and a full X.509 certificate toolkit. The heavy lifting happens in Rust and OpenSSL bindings, so it is fast and memory-safe where it matters. It supports Python 3.9+ and PyPy, and almost every Python package that touches TLS or certificates depends on it.
If Python code needs real cryptography, this is the default and it earns it: audited primitives, honest documentation, and relentless maintenance. Stay on the recipes layer unless you know exactly why you are in hazmat.
Use it if
- You need symmetric encryption with sane defaults and zero decisions: Fernet gives you authenticated encryption, key generation, and TTL-based expiry in four lines
- You work with X.509: loading, inspecting, building, or signing certificates and CSRs is a first-class, well-documented API here
- You need standard primitives (AES-GCM, ChaCha20-Poly1305, RSA, Ed25519, HKDF, PBKDF2) implemented over OpenSSL with a Python API that names its footguns
- You want the library the rest of the ecosystem already trusts: paramiko, pyOpenSSL, certbot, and most TLS tooling build on it
- You only need password hashing: bcrypt or argon2-cffi are smaller, purpose-built dependencies, and hashlib.pbkdf2_hmac ships with Python itself
- You are designing your own protocol from hazmat primitives without a cryptographer reviewing it; the layer is named hazmat because misuse (nonce reuse, wrong padding, unauthenticated modes) fails silently and catastrophically
- You target platforms without prebuilt wheels (Alpine with old pip, niche architectures): building from source requires a Rust toolchain plus OpenSSL headers, which regularly breaks CI images
- You need to pin one version for years: the project ships majors on a fast cadence (version 50.0.0 now) and routinely drops old Python and OpenSSL versions, so stale pins collect CVEs and conflicts
Setup reality
pip install cryptography is painless on Linux/macOS/Windows because binary wheels cover the common platforms. The pain starts off the paved road: musl-based images with outdated pip, FreeBSD, or unusual CPU architectures compile from source, which needs Rust, a C compiler, and OpenSSL development headers, and the error messages are long. The version number moves fast and each major prunes support for old OpenSSL and Python releases, so a requirements file pinned two years ago often will not install on a new box. Imports are verbose by design: expect five-line import blocks from cryptography.hazmat.primitives before you encrypt anything.
Patterns
Encrypt and decrypt with Fernet (the safe default)symmetric-encrypt
from cryptography.fernet import Fernet
key = Fernet.generate_key() # store this somewhere safe
f = Fernet(key)
token = f.encrypt(b"A really secret message.")
plaintext = f.decrypt(token)Fernet is authenticated encryption with all choices made for you; decrypt raises InvalidToken on tampering or a wrong key.
Time-limited tokens with Fernet TTLtoken-expiry
import time
from cryptography.fernet import Fernet
f = Fernet(Fernet.generate_key())
token = f.encrypt(b"session-payload")
# rejects tokens older than 60 seconds
f.decrypt(token, ttl=60)The timestamp is embedded at encrypt time and checked against the current clock, so clock skew between machines breaks TTL validation.
AES-GCM authenticated encryption (hazmat)aes-gcm
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce, NEVER reuse with the same key
ct = aesgcm.encrypt(nonce, b"secret data", b"associated data")
pt = aesgcm.decrypt(nonce, ct, b"associated data")Nonce reuse with the same key destroys both confidentiality and integrity; store the nonce alongside the ciphertext, it is not secret.
Derive an encryption key from a password (PBKDF2)derive-key-password
import base64, os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
salt = os.urandom(16) # store with the ciphertext
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=1_200_000,
)
key = base64.urlsafe_b64encode(kdf.derive(b"my password")) # Fernet-readyKDF instances are single-use; calling derive twice raises AlreadyFinalized. Scale iterations to your latency budget per current guidance.
Compute a SHA-256 digesthash-digest
from cryptography.hazmat.primitives import hashes
digest = hashes.Hash(hashes.SHA256())
digest.update(b"abc")
digest.update(b"123") # incremental updates allowed
result = digest.finalize()For plain hashing, stdlib hashlib is equally fine and dependency-free; use this API when the rest of your crypto already lives here.
Sign and verify a message with HMAChmac-verify
from cryptography.hazmat.primitives import hashes, hmac
h = hmac.HMAC(key, hashes.SHA256())
h.update(b"message to authenticate")
signature = h.finalize()
# verification (constant-time):
h2 = hmac.HMAC(key, hashes.SHA256())
h2.update(b"message to authenticate")
h2.verify(signature) # raises InvalidSignature on mismatchverify() is constant-time; never compare MACs with ==, and remember each HMAC object is consumed by finalize/verify.
Generate an RSA key and export encrypted PEMrsa-generate-serialize
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(b"passphrase"),
)Use NoEncryption() only for keys that live in a real secret store; loading back is serialization.load_pem_private_key(pem, password=b"passphrase").
Sign and verify with RSA-PSSrsa-sign-verify
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
message = b"important payload"
signature = private_key.sign(
message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
public_key = private_key.public_key()
public_key.verify( # raises InvalidSignature on failure
signature,
message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)verify() returns None on success and raises on failure; do not write if verify(...) checks. Prefer PSS over PKCS1v15 for new signatures.
Modern signatures with Ed25519ed25519-sign
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
signature = private_key.sign(b"my authenticated message")
public_key = private_key.public_key()
public_key.verify(signature, b"my authenticated message")No padding or hash choices to get wrong, which is exactly why Ed25519 is the right default for new signature designs.
Load and inspect an X.509 certificatex509-inspect-cert
from cryptography import x509
cert = x509.load_pem_x509_certificate(pem_bytes)
print(cert.subject.rfc4514_string())
print(cert.not_valid_after_utc) # timezone-aware datetime
print(cert.serial_number)Use the _utc properties (not_valid_after_utc); the naive-datetime originals are deprecated and warn on modern versions.
Create a self-signed certificatex509-self-signed
import datetime
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False)
.sign(key, hashes.SHA256())
)Browsers validate the SubjectAlternativeName extension, not the common name; a cert without SAN fails in every modern client.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pycryptodome | PyPI | You need algorithms pyca/cryptography refuses to ship or a self-contained build with no OpenSSL dependency |
| pynacl | PyPI | You want libsodium's tiny, hard-to-misuse API (secretbox, box, sign) and do not need X.509 or OpenSSL compatibility |
| bcrypt | PyPI | The only thing you are doing is hashing passwords; same pyca maintainers, far smaller surface |