Safeguard
Best Practices

Symmetric vs asymmetric file encryption in Python, done correctly

AES-GCM needs a unique 96-bit nonce every single time — reuse one under the same key and GCM's authentication guarantee collapses entirely, not just confidentiality.

Safeguard Research Team
Research
Updated 7 min read

Python's cryptography library — the de facto standard maintained by the Python Cryptographic Authority since 2013 — exposes both a symmetric AEAD cipher (AES-GCM) and asymmetric primitives (RSA, ECC) through its hazmat (hazardous materials) layer, and the name is not decorative. Get the nonce handling wrong in AES-GCM and you do not get slightly weaker encryption; you get complete authentication failure, because GCM derives its authentication key from the same keystream block that nonce reuse also exposes. NIST SP 800-38D, the specification governing GCM, caps random 96-bit nonces at roughly 2^32 encryptions under a single key before collision risk becomes non-negligible — a limit that is trivial to blow through if a service reuses one file-encryption key across millions of uploads. On the asymmetric side, RSA cannot encrypt a file directly at all: a 2048-bit RSA-OAEP key tops out at around 190 bytes of plaintext per operation, which is why every production system pairs it with a symmetric cipher in a hybrid scheme. This post is a from-scratch, working-code tutorial for both approaches — practical Python encryption, not textbook theory — built around the mistakes that turn "I encrypted a file" into a real vulnerability.

How do you correctly encrypt a file with AES-GCM in Python?

Generate a random key, generate a fresh random 12-byte nonce for every encryption operation, and never let the same nonce/key pair encrypt two different messages. Using cryptography.hazmat.primitives.ciphers.aead.AESGCM:

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 bits — generate fresh per call, never reuse

with open("report.pdf", "rb") as f:
    plaintext = f.read()

ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)

with open("report.pdf.enc", "wb") as f:
    f.write(nonce + ciphertext)  # store nonce alongside ciphertext

GCM is an AEAD construction: the ciphertext output already includes a 16-byte authentication tag appended at the end, so decrypt() raises InvalidTag automatically if either the ciphertext or the nonce has been tampered with — no separate HMAC step required, unlike CBC mode. The cryptography.io hazmat AEAD documentation notes that NIST recommends the 96-bit nonce length "for best performance," and that recommendation is load-bearing, not cosmetic — it is the length GCM's internal counter mode was designed around.

Why is AES-GCM nonce reuse catastrophic rather than just risky?

Because GCM derives its authentication subkey from encrypting an all-zero block with the same keystream the nonce generates, reusing a nonce under one key does not just leak the XOR of two plaintexts (bad enough on its own) — it lets an attacker recover the authentication key itself and forge valid ciphertexts for arbitrary messages. This is a well-documented cryptographic result, not a theoretical edge case: once two ciphertexts share a nonce and key, an attacker who has one known plaintext can algebraically solve for GCM's hash subkey H and then produce forgeries that pass authentication. The failure mode in real codebases is almost always the same shape: a developer initializes nonce = os.urandom(12) once at module load or reuses a "session nonce" across multiple encrypt() calls to save a syscall, rather than calling os.urandom(12) fresh inside the per-file encryption function. The fix is procedural as much as technical: never accept a nonce parameter with a default value, and if a key must encrypt more than a few billion files, rotate the key well before the 2^32-encryption ceiling NIST SP 800-38D describes, or switch to a random 192-bit nonce variant to push the collision math out further.

How should large files be encrypted without loading them entirely into memory?

By chunking the file into fixed-size blocks and giving each chunk its own unique nonce, rather than calling AESGCM.encrypt() once on an entire multi-gigabyte file read into RAM. A simple, explicit streaming pattern:

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

def encrypt_stream(key: bytes, infile, outfile, chunk_size: int = 64 * 1024):
    aesgcm = AESGCM(key)
    base_nonce = os.urandom(8)
    counter = 0
    while chunk := infile.read(chunk_size):
        nonce = base_nonce + struct.pack(">I", counter)  # 8-byte random + 4-byte counter = 12 bytes
        ct = aesgcm.encrypt(nonce, chunk, associated_data=None)
        outfile.write(struct.pack(">I", len(ct)) + ct)
        counter += 1

This is the same construction used by battle-tested streaming AEAD designs: a random per-file prefix combined with a monotonic counter guarantees nonce uniqueness within that file without needing 12 fresh random bytes per chunk. pyca/cryptography added buffer-oriented encrypt_into()/decrypt_into() methods to avoid extra allocations per chunk, but it still leaves nonce management — the part that actually matters for security — entirely to the caller, which is why hand-rolled chunk-and-counter code like the above, or a purpose-built library such as Google's Tink, remains the practical choice for large-file work today.

Why can't you just RSA-encrypt a file directly, and what's the correct hybrid pattern?

RSA-OAEP's maximum plaintext length is bounded by the key's modulus size minus padding overhead — for a 2048-bit key with SHA-256 OAEP, that ceiling is roughly 190 bytes, nowhere near enough for a real file. The standard fix, used by every practical system from PGP to TLS certificates wrapping session keys, is hybrid encryption: generate a random AES key, encrypt the file with AES-GCM as above, then encrypt only that small AES key with RSA-OAEP — a concrete example of asymmetric encryption solving the one problem symmetric ciphers can't: sharing a key with someone you've never met.

from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes

wrapped_key = public_key.encrypt(
    aes_key,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    ),
)

Use OAEP, never PKCS1v15 padding, for RSA encryption — PKCS1v15 is the padding scheme broken by Daniel Bleichenbacher's 1998 padding-oracle attack against SSL, and cryptography's own docs mark padding.PKCS1v15() for encryption as legacy-only. Current practical guidance treats 1024-bit RSA as broken, 2048-bit as the realistic minimum, and 3072–4096-bit as the choice for data that needs protection beyond the next decade, always with the standard public exponent 65537.

Is ECC a better choice than RSA for this, and what changes?

RSA-OAEP and ECC/X25519 are, in practice, the two types of asymmetric encryption worth knowing for file-encryption work in Python today. Elliptic-curve alternatives like X25519 achieve comparable security to 3072-bit RSA with a 256-bit key and noticeably faster key generation and key-exchange operations, which is why modern protocol design (TLS 1.3, Signal, WireGuard) defaults to curve-based key agreement over RSA. The tradeoff in Python is architectural, not just performance: X25519 is a key-agreement (ECDH) primitive, not a direct "encrypt this blob" function like RSA-OAEP, so you derive a shared secret with X25519PrivateKey.exchange() and then run that shared secret through a KDF — cryptography.hazmat.primitives.kdf.hkdf.HKDF — to produce the AES key that actually wraps the file. That extra HKDF step is itself a common pitfall source: using the raw ECDH shared secret directly as an AES key, instead of passing it through HKDF first, skips the uniform-key-distribution guarantee HKDF provides and is explicitly warned against in the cryptography library's own ECDH documentation.

What are the most common ways this goes wrong in real codebases?

Beyond nonce reuse, four mistakes recur constantly in production Python encryption code. First, deriving keys from passwords with a hand-rolled hash instead of PBKDF2HMAC, Scrypt, or HKDF from cryptography.hazmat.primitives.kdf — a single SHA-256 pass on a password is fast enough for GPU brute-forcing at billions of guesses per second, while Scrypt's memory-hardness deliberately slows that down. Second, falling back to AES-ECB mode because it's the simplest Cipher call to write — ECB leaks plaintext structure since identical blocks always encrypt identically, the textbook illustration being the "ECB penguin" image that remains visibly a penguin after encryption. Third, treating cryptography.fernet.Fernet (AES-128-CBC + HMAC-SHA256) and raw AES-GCM as interchangeable; Fernet is the right choice specifically because it manages IV generation and authentication internally, removing the nonce-discipline burden GCM places on the caller. Fourth, and most common in the wild: hardcoding the symmetric key or an RSA/EC private key directly in source or committing a .pem file to Git history. Safeguard's secrets scanning detects exactly this pattern — generic RSA, EC, and Ed25519 private-key formats alongside high-entropy strings — across both live code and full Git history, catching a key checked in and later "removed" in a follow-up commit that a plain git grep HEAD would never surface.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.