Symmetric Encryption in Python: AES and Block Ciphers
Symmetric encryption uses a single secret key to encrypt and decrypt data. AES (Advanced Encryption Standard) is the industry standard and is used to protect gigabytes of data daily in banking, government, and cloud infrastructure. Understanding how to implement AES correctly in Python—choosing the right mode (CBC, GCM), generating strong keys, and handling initialization vectors—is essential for protecting data at rest and in transit.
Symmetric encryption is fast (microseconds per megabyte) and is the workhorse of confidentiality in applications. Unlike asymmetric encryption (which is slower and used primarily for key exchange), symmetric encryption scales to large files and streams. The tradeoff is key distribution: both parties must securely share the same key.
How Symmetric Encryption Works
Symmetric encryption transforms plaintext using an algorithm and a secret key: plaintext + key → ciphertext. Decryption reverses the process: ciphertext + key → plaintext. The same key both encrypts and decrypts.
AES is a block cipher: it encrypts fixed-size blocks of data (128 bits) repeatedly. The key sizes are 128, 192, or 256 bits. Longer keys provide stronger security but are slower to process. AES-256 is the standard for highly sensitive data (government classified, financial records); AES-128 is sufficient for most applications.
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os
# Generate a random 256-bit key (32 bytes)
key = os.urandom(32) # AES-256
print(f"Key (hex): {key.hex()}")
# For small data, use AES-GCM (includes authentication)
plaintext = b"Sensitive customer data: SSN 123-45-6789"
iv = os.urandom(12) # 96-bit IV for GCM (12 bytes)
# Encrypt
cipher = Cipher(
algorithms.AES(key),
modes.GCM(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
tag = encryptor.tag # Authentication tag
print(f"Ciphertext: {ciphertext.hex()}")
print(f"Auth tag: {tag.hex()}")
# Decrypt
decipher = Cipher(
algorithms.AES(key),
modes.GCM(iv, tag),
backend=default_backend()
)
decryptor = decipher.decryptor()
recovered = decryptor.update(ciphertext) + decryptor.finalize()
print(f"Decrypted: {recovered}") # b'Sensitive customer data: SSN 123-45-6789'
Encryption Modes: CBC vs. GCM
Block ciphers encrypt fixed blocks of data. For messages longer than 128 bits, the plaintext must be split into blocks and encrypted in sequence. An encryption mode defines how blocks are chained together. The two most common modes are CBC (Cipher Block Chaining) and GCM (Galois/Counter Mode).
CBC mode encrypts each block using the previous ciphertext block as input, creating a chain. It requires padding (adding dummy bytes to align the plaintext to a block boundary) and an initialization vector (IV) to randomize the first block.
GCM mode combines encryption with authentication (AEAD: Authenticated Encryption with Associated Data). It simultaneously encrypts the plaintext and produces an authentication tag. GCM is superior for most use cases because it prevents tampering: an attacker cannot modify the ciphertext without invalidating the tag.
| Property | CBC | GCM |
|---|---|---|
| Authentication | None (separate HMAC required) | Built-in (produces tag) |
| IV size | 16 bytes (128 bits) | 12 bytes (96 bits) |
| Padding | Required | Not required |
| Nonce reuse risk | Low (IV reuse weakens security gradually) | HIGH (reusing IV breaks security completely) |
| Use case | Legacy systems, compatibility | New applications, recommended for 2026 |
For new code in 2026, use GCM mode exclusively. It is simpler (no padding), includes authentication, and prevents tampering.
AES-GCM: The Recommended Pattern
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import os
class SymmetricEncryption:
"""Encrypt and decrypt data using AES-256-GCM."""
KEY_SIZE = 32 # 256 bits
IV_SIZE = 12 # 96 bits (recommended for GCM)
@staticmethod
def generate_key() -> bytes:
"""Generate a random 256-bit key."""
return os.urandom(SymmetricEncryption.KEY_SIZE)
@staticmethod
def encrypt(key: bytes, plaintext: bytes) -> bytes:
"""
Encrypt plaintext using AES-256-GCM.
Args:
key: 256-bit (32-byte) encryption key.
plaintext: Data to encrypt.
Returns:
A byte string: IV (12 bytes) + ciphertext + tag (16 bytes).
"""
if len(key) != SymmetricEncryption.KEY_SIZE:
raise ValueError(f"Key must be {SymmetricEncryption.KEY_SIZE} bytes.")
iv = os.urandom(SymmetricEncryption.IV_SIZE)
cipher = Cipher(
algorithms.AES(key),
modes.GCM(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
tag = encryptor.tag
# Return IV + ciphertext + tag (receiver needs IV and tag to decrypt)
return iv + ciphertext + tag
@staticmethod
def decrypt(key: bytes, encrypted_data: bytes) -> bytes:
"""
Decrypt AES-256-GCM encrypted data.
Args:
key: 256-bit (32-byte) encryption key.
encrypted_data: IV + ciphertext + tag (as returned by encrypt()).
Returns:
The decrypted plaintext.
Raises:
cryptography.exceptions.InvalidTag: If authentication fails.
"""
if len(key) != SymmetricEncryption.KEY_SIZE:
raise ValueError(f"Key must be {SymmetricEncryption.KEY_SIZE} bytes.")
# Extract IV (first 12 bytes), tag (last 16 bytes), ciphertext (middle)
iv = encrypted_data[:SymmetricEncryption.IV_SIZE]
tag = encrypted_data[-16:]
ciphertext = encrypted_data[SymmetricEncryption.IV_SIZE:-16]
cipher = Cipher(
algorithms.AES(key),
modes.GCM(iv, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext
# Example usage
key = SymmetricEncryption.generate_key()
plaintext = b"Confidential: Account balance $50,000"
encrypted = SymmetricEncryption.encrypt(key, plaintext)
print(f"Encrypted ({len(encrypted)} bytes): {encrypted.hex()}")
decrypted = SymmetricEncryption.decrypt(key, encrypted)
print(f"Decrypted: {decrypted}")
# Authentication works: tampering with ciphertext raises InvalidTag
import cryptography.exceptions
tampered = bytearray(encrypted)
tampered[20] ^= 1 # Flip one bit
try:
SymmetricEncryption.decrypt(key, bytes(tampered))
except cryptography.exceptions.InvalidTag:
print("Tampering detected! Authentication tag is invalid.")
Encrypting Data at Rest: Files and Databases
Symmetric encryption is used to protect sensitive data stored on disk. Here's a pattern for encrypting files:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
def encrypt_file(plaintext_path: str, encrypted_path: str, key: bytes) -> None:
"""Encrypt a file using AES-256-GCM."""
iv = os.urandom(12)
with open(plaintext_path, "rb") as f_in:
plaintext = f_in.read()
cipher = Cipher(
algorithms.AES(key),
modes.GCM(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
tag = encryptor.tag
# Write IV + ciphertext + tag
with open(encrypted_path, "wb") as f_out:
f_out.write(iv + ciphertext + tag)
def decrypt_file(encrypted_path: str, plaintext_path: str, key: bytes) -> None:
"""Decrypt a file encrypted with AES-256-GCM."""
with open(encrypted_path, "rb") as f_in:
encrypted_data = f_in.read()
iv = encrypted_data[:12]
tag = encrypted_data[-16:]
ciphertext = encrypted_data[12:-16]
cipher = Cipher(
algorithms.AES(key),
modes.GCM(iv, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
with open(plaintext_path, "wb") as f_out:
f_out.write(plaintext)
# Example
key = SymmetricEncryption.generate_key()
encrypt_file("sensitive.txt", "sensitive.txt.enc", key)
decrypt_file("sensitive.txt.enc", "recovered.txt", key)
Key Management for Symmetric Encryption
The security of symmetric encryption depends entirely on key secrecy. A key that leaks or is guessed makes all encrypted data readable. Best practices:
Generate keys cryptographically: Use os.urandom() or cryptographic libraries, never weak randomness like random.randint().
Store keys securely: Never hardcode keys in source code. Use environment variables for development, and secrets management systems (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) for production.
Rotate keys periodically: Every 1-2 years, generate a new key and re-encrypt older data. Separate keys per environment (dev, staging, production).
Never reuse IVs with the same key: In GCM mode, reusing an IV with the same key completely breaks security. Generate a new IV for every encryption operation.
Key Takeaways
- Symmetric encryption uses a single key to encrypt and decrypt; AES is the industry standard.
- AES-256 with GCM mode is the recommended choice for 2026: it includes authentication, prevents tampering, and is simple to implement.
- Always generate a new IV (nonce) for every encryption operation; never reuse an IV with the same key.
- Store encrypted data as: IV (12 bytes) + ciphertext + authentication tag (16 bytes). The receiver needs the IV and tag to decrypt and verify.
- Key management is critical: never hardcode keys, rotate them periodically, and use secure storage systems.
Frequently Asked Questions
Why is GCM mode better than CBC?
GCM includes authentication (prevents tampering), requires no padding, and has a simpler implementation. CBC requires separate HMAC for authentication, which is error-prone. Use GCM exclusively for new code.
What happens if I reuse an IV in GCM mode?
Complete security failure. An attacker can recover the key or decrypt messages. Always generate a new random IV for every encryption with the same key. This is non-negotiable.
Can I use AES-128 instead of AES-256?
Yes, AES-128 is still secure in 2026 and is faster. Use AES-128 for high-throughput scenarios (large files, video streaming). Use AES-256 for long-term storage (data must remain confidential for decades) and highly sensitive data.
How do I encrypt large files without loading them into memory?
Use streaming encryption: read the file in chunks, encrypt each chunk with the same IV, and append to the output file. The cryptography library supports this via encryptor.update(chunk) for chunked encryption.
Should I encrypt before or after compressing?
Always compress first, then encrypt. Encryption produces random-looking output that doesn't compress; encrypting first wastes compression's effectiveness. Order: plaintext → compress → encrypt.