Secure Key Management in Python: Storage and Rotation
Cryptographic security is only as strong as the keys protecting the data. A leaked encryption key exposes all data encrypted with that key; a hardcoded password in source code is a permanent backdoor. Key management is the practice of generating, storing, rotating, and retiring keys securely. Poor key management is the root cause of most real-world breaches, not broken cryptographic algorithms.
In 2026, storing cryptographic secrets in source code, environment variables, or unencrypted configuration files is unacceptable. Organizations must use dedicated secrets management systems with access controls, audit logs, automatic rotation, and encryption at rest. A Python developer's responsibility is knowing how to integrate with these systems and understanding the security implications of each approach.
The Key Lifecycle
Every cryptographic key has a lifecycle:
-
Generation: Create a new key using cryptographically secure randomness. Generate keys separately for each environment (dev, staging, production) and each purpose (encryption, signing).
-
Storage: Store keys securely with restricted access (only the application can read). Use secrets management systems, not source code or plaintext files.
-
Use: The application retrieves the key at runtime and uses it for encryption/decryption or signing/verification.
-
Rotation: Periodically generate a new key, re-encrypt data with the new key (or keep old key for decryption during transition), and retire the old key.
-
Retirement: Once a key is rotated and all data has been re-encrypted, securely delete the old key so it cannot be recovered.
Anti-Patterns: How NOT to Manage Keys
Hardcoding keys in source code is the most common mistake. Hardcoded keys are exposed in:
- Git history (even if deleted from current code,
git logretains it). - Backups and archives.
- Memory dumps during debugging.
- Logs if the key is printed (e.g.,
print(f"Key: {key}")).
# WRONG: Hardcoded key (NEVER DO THIS)
API_KEY = "sk-1234567890abcdefghijklmnop"
encryption_key = b"0123456789ABCDEF0123456789ABCDEF" # 32 bytes
def encrypt_data(plaintext):
cipher = Fernet(encryption_key)
return cipher.encrypt(plaintext.encode())
If this code is committed, the key is compromised forever. An attacker can extract it from Git history even after the developer deletes it from the file.
Storing keys in plaintext configuration files on the server is only slightly better. If an attacker gains file-system access, they read the key.
Using weak randomness (e.g., random.Random(), uuid, predictable sources) for key generation defeats cryptography. Keys must be generated by cryptographic libraries using OS entropy (os.urandom(), secrets module).
Best Practice: Environment Variables (Development Only)
For local development, environment variables are acceptable as a temporary measure:
import os
from cryptography.fernet import Fernet
# Read key from environment variable (development only)
encryption_key = os.getenv("ENCRYPTION_KEY")
if not encryption_key:
raise ValueError("ENCRYPTION_KEY environment variable not set!")
cipher = Fernet(encryption_key.encode())
plaintext = b"Sensitive data"
ciphertext = cipher.encrypt(plaintext)
print(ciphertext)
The key is stored in the shell environment, not in source code. This is suitable for developers and CI/CD pipelines, but environment variables are not secure for production. They are visible in process listings, container images, and logs.
# In development, load from .env file (never commit .env!)
export ENCRYPTION_KEY="zH84jk1Yy3t5vW8xZ2qP4mN6bL9dF1jK"
python app.py
Production: Secrets Management Systems
Production applications must use dedicated secrets management systems:
AWS Secrets Manager
import boto3
import json
def get_secret(secret_name, region_name="us-east-1"):
"""Retrieve a secret from AWS Secrets Manager."""
client = boto3.client("secretsmanager", region_name=region_name)
try:
response = client.get_secret_value(SecretId=secret_name)
if "SecretString" in response:
return response["SecretString"]
else:
return response["SecretBinary"]
except client.exceptions.ResourceNotFoundException:
raise ValueError(f"Secret {secret_name} not found in AWS Secrets Manager")
# Example: Retrieve encryption key
encryption_key_secret = get_secret("prod/encryption-key")
# Returns: "zH84jk1Yy3t5vW8xZ2qP4mN6bL9dF1jK"
# If the secret is JSON (e.g., database credentials)
db_secret = json.loads(get_secret("prod/database-credentials"))
username = db_secret["username"]
password = db_secret["password"]
Advantages:
- Keys are encrypted at rest in AWS and in transit.
- Access is controlled via IAM roles (only specific applications can read).
- All access is logged for audit.
- Automatic rotation is supported.
- No keys are ever visible in logs or source code.
HashiCorp Vault
Vault is an open-source secrets management system that works on-premises or in the cloud:
import hvac
import json
def get_secret_from_vault(secret_path, vault_url="http://127.0.0.1:8200", token=None):
"""Retrieve a secret from HashiCorp Vault."""
if not token:
# Authenticate using environment variable (for CI/CD)
token = os.getenv("VAULT_TOKEN")
client = hvac.Client(url=vault_url, token=token)
try:
response = client.secrets.kv.read_secret_version(path=secret_path)
return response["data"]["data"]
except Exception as e:
raise ValueError(f"Failed to retrieve secret from Vault: {e}")
# Example
encryption_key = get_secret_from_vault("secret/prod/encryption-key")
Vault provides similar benefits: encryption, access control, audit logs, and rotation.
Azure Key Vault
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
def get_secret_from_azure_keyvault(secret_name, vault_url):
"""Retrieve a secret from Azure Key Vault."""
credential = DefaultAzureCredential()
client = SecretClient(vault_url=vault_url, credential=credential)
secret = client.get_secret(secret_name)
return secret.value
# Example
vault_url = "https://myvault.vault.azure.net/"
encryption_key = get_secret_from_azure_keyvault("encryption-key", vault_url)
Key Rotation Strategy
Key rotation is essential for long-term security. If a key is compromised, rotation limits the exposure window. A practical rotation strategy:
- Generate a new key with a new rotation ID (e.g., timestamp).
- Keep the old key available for decryption (new data uses the new key; old data uses the old key).
- Re-encrypt old data in the background (if feasible).
- Retire the old key after a grace period (e.g., 30 days).
from datetime import datetime, timedelta
import json
class RotatingKeyManager:
"""Manage multiple keys for rotation."""
def __init__(self, get_secret_func):
"""
Args:
get_secret_func: Function to retrieve secrets from storage.
"""
self.get_secret_func = get_secret_func
self._keys_cache = None
self._cache_time = None
def get_active_key(self):
"""Get the current (most recent) key for encryption."""
keys = self._load_keys()
return keys[0]["key"] # First key is newest
def get_all_keys(self):
"""Get all keys (active + previous) for decryption."""
return self._load_keys()
def _load_keys(self):
"""Load keys from secrets manager (with caching)."""
# Cache for 5 minutes to avoid constant lookups
now = datetime.utcnow()
if self._keys_cache and self._cache_time and (now - self._cache_time) < timedelta(minutes=5):
return self._keys_cache
# Retrieve key rotation manifest from secrets manager
manifest = json.loads(self.get_secret_func("prod/key-rotation-manifest"))
# Manifest format:
# {
# "keys": [
# {"id": "2026-06-02", "key": "...", "created": "2026-06-02T10:00:00Z"},
# {"id": "2026-05-02", "key": "...", "created": "2026-05-02T10:00:00Z"}
# ]
# }
self._keys_cache = manifest["keys"]
self._cache_time = now
return self._keys_cache
def encrypt_with_active_key(self, plaintext):
"""Encrypt with the active (newest) key."""
from cryptography.fernet import Fernet
key = self.get_active_key()
cipher = Fernet(key)
return cipher.encrypt(plaintext.encode())
def decrypt_with_any_key(self, ciphertext):
"""Try decryption with all keys until one succeeds."""
from cryptography.fernet import Fernet, InvalidToken
for key_info in self.get_all_keys():
try:
cipher = Fernet(key_info["key"])
plaintext = cipher.decrypt(ciphertext)
return plaintext
except InvalidToken:
continue
raise ValueError("Decryption failed with all available keys")
# Usage
def get_secret(name):
# Replace with actual secrets manager call
pass
key_manager = RotatingKeyManager(get_secret)
ciphertext = key_manager.encrypt_with_active_key("Sensitive data")
plaintext = key_manager.decrypt_with_any_key(ciphertext)
Never Log Secrets
Logging is a common vector for key leaks. A single print(f"Key: {key}") or logger.debug(config) exposes the key to logs, which are often stored unencrypted.
import logging
logger = logging.getLogger(__name__)
# WRONG: Logging secrets
api_key = get_secret("api-key")
logger.info(f"API key: {api_key}") # LEAKED
# CORRECT: Never log secrets, log safe information
logger.info(f"Retrieved API key, length: {len(api_key)} characters")
# Use masking in logs if you must reference secrets
def mask_secret(secret, visible_chars=4):
"""Mask a secret, showing only the first N characters."""
if len(secret) <= visible_chars:
return "*" * len(secret)
return secret[:visible_chars] + "*" * (len(secret) - visible_chars)
logger.debug(f"Using API key: {mask_secret(api_key)}")
Key Takeaways
- Never hardcode secrets in source code. Hardcoded keys are exposed in Git history, backups, and logs.
- Use environment variables for development and CI/CD; use secrets management systems (AWS Secrets Manager, Vault, Azure Key Vault) for production.
- Implement key rotation: generate new keys periodically, keep old keys for decryption during transition, retire old keys after a grace period.
- Cache keys in memory (with a short TTL) to avoid constant lookups; refresh when the cache expires.
- Never log secrets; log safe metadata (e.g., key length, key ID) instead.
- Use separate keys for each environment and purpose (encryption key ≠ signing key).
Frequently Asked Questions
How often should I rotate keys?
Annually for long-lived keys protecting permanent data (encryption keys for customer data, signing keys for certificates). Quarterly for short-lived keys (API keys, session tokens). Rotate immediately if there is evidence of compromise.
What if I rotate a key but old data was encrypted with the old key?
Keep the old key available for decryption. Store the key ID alongside the ciphertext (or in a manifest) so the application knows which key was used for each piece of data. Decrypt with the correct key; re-encrypt with the new key during a background maintenance window.
Can I derive multiple keys from a single master key?
Yes, using key derivation functions (KDF) like PBKDF2 or HKDF. You can derive encryption keys, signing keys, and database keys from one master key. This simplifies key management (rotate once master key, derive new subkeys) but still requires the master key to be secure.
How do I securely delete a retired key?
Use the secrets module to generate keys, which uses OS randomness. When retiring, overwrite the key in memory multiple times before deletion (to prevent recovery from a memory dump). Most secrets management systems provide secure deletion APIs that do this automatically.
Is environment variable injection a security risk?
Yes. An attacker who can control environment variables (via a compromised CI/CD system or container orchestration) can inject malicious secrets. Mitigate by: restricting environment variable modification to specific trusted users, using signed/encrypted configuration, and validating secret values at runtime.