Python's cryptography package — the de facto standard library for encryption on PyPI, now at version 49.0.0 — ships two very different tools under one roof: Fernet, a symmetric recipe that wraps AES-128-CBC with an HMAC-SHA256 authentication tag, and the hazmat.primitives.asymmetric.rsa module, which generates public/private keypairs for encrypting small payloads or exchanging keys. Confusing the two is a common source of real bugs: teams reach for RSA to encrypt a multi-megabyte file (RSA has strict ciphertext-size limits tied to key size, so it can't), or they reach for Fernet in a scenario with no pre-shared secret, like sending data to a party they've never talked to before. Both primitives are correct and well-audited when used for the job they're designed for — the failures come from picking the wrong one, or from using legacy padding schemes like PKCS#1 v1.5, which remains vulnerable to the class of padding-oracle attacks Daniel Bleichenbacher first demonstrated against RSA in 1998 and which still surfaces in TLS implementations decades later. This post walks through working code for both approaches in Python, explains the mechanics behind each, and lays out concrete rules for when symmetric, asymmetric, or a hybrid of the two is the right call.
What does symmetric encryption actually look like in Python?
Symmetric encryption uses one key for both encrypting and decrypting, and the simplest correct way to do it in Python is cryptography.fernet.Fernet. Fernet is a recipe, not a raw primitive: under the hood it generates a random 128-bit IV, encrypts your data with AES-128 in CBC mode, then computes an HMAC-SHA256 over the IV and ciphertext so any tampering is detectable on decrypt. The output is a URL-safe base64 token that embeds a timestamp, which lets you enforce a time-to-live with a single argument:
from cryptography.fernet import Fernet
key = Fernet.generate_key() # 32 random bytes, keep this secret
f = Fernet(key)
token = f.encrypt(b"account balance: $4,200")
f.decrypt(token, ttl=300) # raises if older than 300 seconds
The library's own documentation steers users toward Fernet (or the AESGCM recipe) rather than composing raw AES-CBC and HMAC by hand, because get the order of encrypt-then-MAC wrong and you reintroduce padding-oracle-class bugs yourself. The one hard requirement: whoever holds key can decrypt everything, so key distribution is the entire security model.
What does asymmetric encryption look like, and why does padding choice matter?
Asymmetric encryption uses a keypair — a public key anyone can encrypt with, and a private key only the recipient holds to decrypt. In Python that's cryptography.hazmat.primitives.asymmetric.rsa:
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
ciphertext = public_key.encrypt(
b"a 32-byte AES key, not a file",
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
)
Two details matter here. First, key size: 2048 bits is the current documented minimum, with 3072 or 4096 recommended for keys that need to stay secure for years. Second, padding: padding.OAEP is the modern, recommended scheme; the older padding.PKCS1v15 is still present in the library for interoperability but is legacy — it's the padding scheme underlying Bleichenbacher's 1998 attack and the reason "ROBOT," a 2017 research finding by Hanno Böck, Juraj Somorovsky, and Craig Young, showed that dozens of major vendors' TLS stacks (including F5 and Citrix products) were still exploitable to it nearly two decades later. New code should default to OAEP and treat PKCS1v15 as a compatibility-only fallback.
Why can't I just use RSA for everything and skip symmetric keys?
RSA can't replace symmetric encryption for bulk data because of a hard mathematical ceiling: with OAEP-SHA256 padding, a 2048-bit RSA key can encrypt at most 190 bytes in a single operation, and every encryption is orders of magnitude slower than AES, which modern CPUs accelerate with dedicated AES-NI instructions. Encrypting a 10 MB file with raw RSA calls isn't just slow, it's not something the API is designed to do at all — you'd have to chunk the file into hundreds of tiny RSA operations and manage their ordering yourself, which is exactly the kind of hand-rolled scheme that introduces the bugs Fernet was built to prevent. This is precisely why TLS, since its earliest versions, never uses RSA or ECDHE to encrypt the actual HTTP payload — it uses asymmetric crypto only to negotiate or exchange a symmetric session key, then switches to AES or ChaCha20 for everything else. That handshake-then-bulk-cipher pattern has a name: hybrid encryption. It is the standard answer to "which one should I use," and Python code that needs both confidentiality and no pre-shared secret should follow the same shape rather than choosing one primitive to do the whole job.
How do I actually build the hybrid pattern in Python?
Building hybrid encryption in cryptography takes about ten lines: generate a random Fernet key, encrypt your real payload with it, then encrypt just that key with the recipient's RSA public key.
symmetric_key = Fernet.generate_key()
payload_ciphertext = Fernet(symmetric_key).encrypt(large_payload_bytes)
wrapped_key = public_key.encrypt(symmetric_key, padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))
You send wrapped_key and payload_ciphertext together. The recipient uses their RSA private key to unwrap symmetric_key, then uses that to decrypt the payload with Fernet. This is the exact shape of a PGP-encrypted email attachment and, structurally, of every TLS 1.2/1.3 session: a small asymmetric operation establishes trust and a shared secret, then a fast symmetric cipher does the heavy lifting. The library also supports the elliptic-curve equivalents — X25519 for key exchange and Ed25519 for signatures — which the cryptography docs recommend over RSA for new designs because the keys are smaller and the operations are faster at equivalent security levels, though RSA keypairs remain far more common in existing PKI and are what you'll encounter integrating with most legacy systems.
When should I actually choose symmetric over asymmetric?
Choose symmetric encryption whenever both parties already share a secret and you're protecting data at rest or in a closed system you control — encrypting a database column, a config file, or a session token, where the encrypting and decrypting service are the same application or team. Choose asymmetric encryption when there's no pre-shared secret and you need one of two properties: confidentiality across an untrusted channel (a client encrypting data only your server's private key can open) or a verifiable signature (proving a document came from a specific keyholder without proving they know a shared secret). In practice, most production systems don't choose one exclusively — they use RSA or ECDHE at connection setup and AES for the data that follows, exactly as TLS does. The failure mode worth watching for in code review isn't usually "wrong algorithm family," it's a team that picked RSA correctly for key exchange but left padding.PKCS1v15 in place from an old example, or one that picked Fernet correctly but has no plan for how the 32-byte key itself gets distributed to a new service.
How Safeguard fits in
Encryption code is only as strong as the keys behind it, which is why Safeguard's secrets scanning specifically detects RSA, EC, and Ed25519 private keys committed to source, container layers, and Git history, verifying against issuer APIs where applicable so a leaked key becomes an actionable, prioritized finding rather than a noisy regex match. On the signing side, Safeguard's attestation pipeline uses the same asymmetric building blocks discussed here — Sigstore's keyless Fulcio certificates or traditional X.509/PKI — to sign SLSA provenance and SBOM attestations for every artifact your pipelines build, so the "no pre-shared secret, need a verifiable signature" case from this post isn't just theory, it's the mechanism securing your supply chain evidence today.