Module 5: Secrets Management
2. Beyond .env: Why It's Not Enough
Overview
The .env file is probably the first secrets management tool you learned. You create a file, put your keys in it, add it to .gitignore, and use python-dotenv to load it. It works. It's simple. And it's exactly what you need for local development. But when your AI system reaches production — with multiple services, teams, and high-value credentials like LLM API keys — .env becomes a vulnerability.
This capsule is not an abstract argument against .env. It's a concrete technical analysis of the five limitations that make .env insufficient for production, with code that demonstrates each vulnerability, real API key exposure incidents, the financial cost of leaked keys, and the transition path toward more robust solutions.
By the end of this capsule you'll be able to articulate exactly why your system needs more than .env, with arguments you can present to your team or stakeholders.
How .env works (and why it seems like enough)
Let's start with what .env does and why it feels like a complete solution:
# .env
OPENAI_API_KEY=sk-proj-abc123def456
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
ANTHROPIC_API_KEY=sk-ant-xyz789
# app.py
import os
from dotenv import load_dotenv
load_dotenv()
openai_key = os.getenv("OPENAI_API_KEY")
db_url = os.getenv("DATABASE_URL")
print(f"OpenAI key loaded: {openai_key[:10]}...")
print(f"Database URL loaded: {db_url[:30]}...")
# Expected output:
# OpenAI key loaded: sk-proj-ab...
# Database URL loaded: postgresql://user:pass@lo...
This pattern is popular for legitimate reasons:
- ✅ Simple: A plain text file
- ✅ Standard:
python-dotenvhas millions of downloads - ✅ Separation: Keys outside the source code
- ✅ Portability: Works in any environment
- ✅ 12-Factor compatible: Follows the principle of config in env vars
The problem isn't that .env is bad — it's that it was designed for local development and has fundamental limitations when you use it in production.
The 5 fatal limitations of .env in production
Limitation 1: No encryption at rest
Your .env file is plain text. Any person or process with filesystem access can read your secrets:
import os
import stat
env_path = ".env"
with open(env_path, "w") as f:
f.write("OPENAI_API_KEY=sk-proj-super-secret-key-12345\n")
f.write("DATABASE_PASSWORD=my-database-password-67890\n")
file_stat = os.stat(env_path)
permissions = oct(file_stat.st_mode)[-3:]
print(f"File: {env_path}")
print(f"Permissions: {permissions}")
print(f"Readable by owner: {bool(file_stat.st_mode & stat.S_IRUSR)}")
print(f"Readable by group: {bool(file_stat.st_mode & stat.S_IRGRP)}")
print(f"Readable by others: {bool(file_stat.st_mode & stat.S_IROTH)}")
with open(env_path, "r") as f:
content = f.read()
print(f"\nContent (plain text):\n{content}")
# Expected output:
# File: .env
# Permissions: 644
# Readable by owner: True
# Readable by group: True
# Readable by others: True
#
# Content (plain text):
# OPENAI_API_KEY=sk-proj-super-secret-key-12345
# DATABASE_PASSWORD=my-database-password-67890
os.remove(env_path)
With 644 permissions (the default on most systems), any user on the server can read your secrets. Even with 600, an attacker who escalates privileges or compromises the app process gets direct access.
Compare with a secrets manager that encrypts at rest:
from cryptography.fernet import Fernet
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)
secret = "sk-proj-super-secret-key-12345"
encrypted = cipher.encrypt(secret.encode())
print(f"Original: {secret}")
print(f"Encrypted: {encrypted[:60]}...")
print(f"Readable? No - you need the encryption key")
decrypted = cipher.decrypt(encrypted).decode()
print(f"Decrypted: {decrypted}")
print(f"Match: {secret == decrypted}")
# Expected output:
# Original: sk-proj-super-secret-key-12345
# Encrypted: gAAAAABn...
# Readable? No - you need the encryption key
# Decrypted: sk-proj-super-secret-key-12345
# Match: True
A secrets manager encrypts each secret with keys that are protected by hardware (HSM) or by the cloud provider. Reading the storage doesn't give you the secrets.
Limitation 2: No automatic rotation
With .env, rotating an API key means:
- Generate a new key in the provider's dashboard (OpenAI, Anthropic, etc.)
- SSH into the production server
- Edit the
.envfile manually - Restart the application
- Verify it works
- Revoke the previous key
This process is manual, error-prone, and causes downtime. In practice, most teams don't rotate keys — and when a key leaks, it stays active indefinitely:
from datetime import datetime, timedelta
import json
def simulate_key_lifecycle_dotenv():
key_created = datetime(2024, 1, 15)
today = datetime(2026, 3, 13)
days_active = (today - key_created).days
lifecycle = {
"key_name": "OPENAI_API_KEY",
"created": key_created.isoformat(),
"last_rotated": "never",
"days_active": days_active,
"rotation_method": "manual (.env edit + restart)",
"estimated_downtime_per_rotation": "2-5 minutes",
"times_rotated": 0,
"risk_assessment": "HIGH" if days_active > 90 else "MEDIUM",
}
return lifecycle
def simulate_key_lifecycle_secrets_manager():
key_created = datetime(2024, 1, 15)
rotation_interval = timedelta(days=30)
today = datetime(2026, 3, 13)
rotations = 0
current_rotation = key_created
while current_rotation + rotation_interval < today:
current_rotation += rotation_interval
rotations += 1
lifecycle = {
"key_name": "openai-api-key",
"created": key_created.isoformat(),
"last_rotated": current_rotation.isoformat(),
"days_since_rotation": (today - current_rotation).days,
"rotation_method": "automatic (secrets manager)",
"downtime_per_rotation": "0 (zero-downtime dual-key)",
"times_rotated": rotations,
"risk_assessment": "LOW",
}
return lifecycle
dotenv = simulate_key_lifecycle_dotenv()
sm = simulate_key_lifecycle_secrets_manager()
print("=== .env Lifecycle ===")
print(json.dumps(dotenv, indent=2))
print(f"\n=== Secrets Manager Lifecycle ===")
print(json.dumps(sm, indent=2))
# Expected output:
# === .env Lifecycle ===
# {
# "key_name": "OPENAI_API_KEY",
# "created": "2024-01-15T00:00:00",
# "last_rotated": "never",
# "days_active": 788,
# "rotation_method": "manual (.env edit + restart)",
# "times_rotated": 0,
# "risk_assessment": "HIGH"
# }
#
# === Secrets Manager Lifecycle ===
# {
# "key_name": "openai-api-key",
# "last_rotated": "2026-03-05T00:00:00",
# "rotation_method": "automatic (secrets manager)",
# "downtime_per_rotation": "0 (zero-downtime dual-key)",
# "times_rotated": 26,
# "risk_assessment": "LOW"
# }
788 days without rotating vs automatic rotation every 30 days. The difference is enormous in terms of risk.
Limitation 3: No audit trail
With .env, there's no record of who accessed which secret or when. If you suspect a key was compromised, you have no way to know when or who read it:
import os
import json
from datetime import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional
@dataclass
class SecretAccess:
timestamp: str
secret_name: str
accessor: str
action: str
source_ip: Optional[str] = None
success: bool = True
class DotenvNoAudit:
"""Simulates how .env works — no audit trail."""
def get_secret(self, name: str) -> Optional[str]:
value = os.environ.get(name)
# There's no record of this access
# We don't know who called, when, or from where
return value
class SecretsManagerWithAudit:
"""Simulates a secrets manager with an audit trail."""
def __init__(self):
self._secrets = {
"openai-api-key": "sk-proj-abc123",
"database-password": "db-pass-xyz",
}
self._audit_log: list[SecretAccess] = []
def get_secret(self, name: str, accessor: str = "unknown") -> Optional[str]:
success = name in self._secrets
access = SecretAccess(
timestamp=datetime.utcnow().isoformat(),
secret_name=name,
accessor=accessor,
action="read",
source_ip="10.0.1.50",
success=success,
)
self._audit_log.append(access)
return self._secrets.get(name)
def get_audit_log(self) -> list[dict]:
return [asdict(entry) for entry in self._audit_log]
sm = SecretsManagerWithAudit()
sm.get_secret("openai-api-key", accessor="api-service")
sm.get_secret("database-password", accessor="migration-job")
sm.get_secret("nonexistent-key", accessor="suspicious-process")
print("Audit Trail:")
for entry in sm.get_audit_log():
status = "✅" if entry["success"] else "❌"
print(f" {status} [{entry['timestamp']}] {entry['accessor']} → {entry['secret_name']}")
# Expected output:
# Audit Trail:
# ✅ [2026-03-13T...] api-service → openai-api-key
# ✅ [2026-03-13T...] migration-job → database-password
# ❌ [2026-03-13T...] suspicious-process → nonexistent-key
With an audit trail, the third access (a suspicious process trying to reach a nonexistent secret) is visible and can trigger alerts. With .env, that access is invisible.
Limitation 4: No granularity (least privilege)
With .env, every process running on the same server sees all the environment variables. Your API service, your background jobs worker, your migration script — they all have access to all the keys:
import os
from dataclasses import dataclass
@dataclass
class ServicePermissions:
service_name: str
needs: list[str]
has_access_to: list[str]
@property
def over_privileged(self) -> bool:
return set(self.has_access_to) - set(self.needs) != set()
@property
def unnecessary_access(self) -> list[str]:
return list(set(self.has_access_to) - set(self.needs))
all_env_secrets = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DATABASE_URL",
"REDIS_URL",
"STRIPE_SECRET_KEY",
"ADMIN_PASSWORD",
]
services_dotenv = [
ServicePermissions(
service_name="api-service",
needs=["OPENAI_API_KEY", "DATABASE_URL"],
has_access_to=all_env_secrets,
),
ServicePermissions(
service_name="background-worker",
needs=["DATABASE_URL", "REDIS_URL"],
has_access_to=all_env_secrets,
),
ServicePermissions(
service_name="migration-script",
needs=["DATABASE_URL"],
has_access_to=all_env_secrets,
),
]
print("=== .env: Everyone sees everything ===")
for svc in services_dotenv:
print(f"\n{svc.service_name}:")
print(f" Needs: {svc.needs}")
print(f" Has access to: {len(svc.has_access_to)} secrets")
print(f" Over-privileged: {svc.over_privileged}")
print(f" Unnecessary access: {svc.unnecessary_access}")
# Expected output:
# === .env: Everyone sees everything ===
#
# api-service:
# Needs: ['OPENAI_API_KEY', 'DATABASE_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['ANTHROPIC_API_KEY', 'REDIS_URL', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
#
# background-worker:
# Needs: ['DATABASE_URL', 'REDIS_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
#
# migration-script:
# Needs: ['DATABASE_URL']
# Has access to: 6 secrets
# Over-privileged: True
# Unnecessary access: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'REDIS_URL', 'STRIPE_SECRET_KEY', 'ADMIN_PASSWORD']
Each service has access to secrets it doesn't need. If an attacker compromises the migration-script, they get the OpenAI and Stripe API keys even though the script only needed database access.
Limitation 5: Doesn't scale with teams
With .env, distributing secrets to a new developer means sharing them over an insecure channel. Revoking a former employee's access means rotating every key they knew:
from dataclasses import dataclass
@dataclass
class TeamScaling:
method: str
onboarding_steps: list[str]
offboarding_steps: list[str]
risk_level: str
dotenv_scaling = TeamScaling(
method="shared .env",
onboarding_steps=[
"1. New dev requests the .env via Slack/email",
"2. Senior dev copies the .env and sends it",
"3. New dev saves it on their laptop",
"4. The .env contains ALL the project's keys",
"5. No record of who has what",
],
offboarding_steps=[
"1. Dev leaves the company",
"2. Did they delete the .env from their laptop? We don't know",
"3. Do they have a copy somewhere else? We don't know",
"4. Solution: rotate ALL the keys they knew",
"5. But do we know which ones they knew? Not exactly",
"6. In practice: nobody rotates anything 😬",
],
risk_level="HIGH",
)
sm_scaling = TeamScaling(
method="Secrets Manager with policies",
onboarding_steps=[
"1. Admin creates a policy for the new dev",
"2. Policy defines which secrets they can read",
"3. Dev authenticates with SSO/IAM",
"4. Accesses only the secrets they need",
"5. Every access is recorded in the audit log",
],
offboarding_steps=[
"1. Dev leaves the company",
"2. Admin revokes their policy/IAM role",
"3. Access cut off immediately",
"4. Audit log shows which secrets they accessed",
"5. Only high-risk secrets are rotated if needed",
],
risk_level="LOW",
)
for scaling in [dotenv_scaling, sm_scaling]:
print(f"\n=== {scaling.method} (Risk: {scaling.risk_level}) ===")
print("Onboarding:")
for step in scaling.onboarding_steps:
print(f" {step}")
print("Offboarding:")
for step in scaling.offboarding_steps:
print(f" {step}")
Real-world API key exposure incidents
These are documented industry patterns — all preventable with secrets management:
Pattern 1: Key committed to Git
import json
incident_1 = {
"pattern": "API key committed to a public repository",
"timeline": [
"T+0min: Developer accidentally commits .env",
"T+2min: Push to GitHub (public repo)",
"T+4min: Automated bot detects the key",
"T+5min: Bot starts using the key",
"T+60min: $2,000 in API usage",
"T+180min: Developer notices the mistake",
"T+185min: Revokes the key manually",
"T+190min: git revert (but the key is still in history)",
],
"total_cost": "$2,000+",
"root_cause": "Plain-text secret accessible from the repo",
"prevention": "Secrets in vault/KMS, never in repo files",
}
print(json.dumps(incident_1, indent=2, ensure_ascii=False))
Pattern 2: Key in a Docker image
incident_2 = {
"pattern": "API key in a Dockerfile or docker-compose.yml",
"dockerfile_vulnerable": """
# VULNERABLE: key hardcoded in the build
FROM python:3.11
ENV OPENAI_API_KEY=sk-proj-real-key-here
COPY . /app
CMD ["python", "app.py"]
""".strip(),
"impact": "Anyone who pulls the image has the key",
"dockerfile_safe": """
# SAFE: key injected at runtime from a secrets manager
FROM python:3.11
COPY . /app
# No ENV with secrets - they're injected at runtime
CMD ["python", "app.py"]
# docker run -e OPENAI_API_KEY=$(vault read -field=value secret/openai)
""".strip(),
}
print("VULNERABLE Dockerfile:")
print(incident_2["dockerfile_vulnerable"])
print(f"\nSAFE Dockerfile:")
print(incident_2["dockerfile_safe"])
Pattern 3: Key in logs
import logging
import json
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("demo")
api_key = "sk-proj-abc123-secret-key"
logger.info(f"Connecting to OpenAI with key: {api_key}")
def safe_log_config(config: dict) -> dict:
"""Redacts sensitive values before logging."""
sensitive_patterns = ["key", "password", "secret", "token"]
safe = {}
for k, v in config.items():
if any(pattern in k.lower() for pattern in sensitive_patterns):
safe[k] = f"{str(v)[:4]}...{str(v)[-4:]}" if len(str(v)) > 8 else "***"
else:
safe[k] = v
return safe
config = {
"openai_api_key": api_key,
"model": "gpt-4o-mini",
"temperature": 0.3,
"database_password": "super-secret-pass",
}
safe_config = safe_log_config(config)
logger.info(f"Config loaded: {json.dumps(safe_config)}")
# Expected output:
# INFO:demo:Connecting to OpenAI with key: sk-proj-abc123-secret-key ← BAD
# INFO:demo:Config loaded: {"openai_api_key": "sk-p...-key", "model": "gpt-4o-mini", ...} ← GOOD
The financial cost of leaked LLM API keys
LLM API keys are especially dangerous because the damage is financial and immediate:
import json
cost_analysis = {
"openai": {
"model": "gpt-4o",
"input_cost_per_1M": 2.50,
"output_cost_per_1M": 10.00,
"calls_per_minute_max": 500,
"scenario": "Attacker makes mass calls for 1 hour",
"estimated_calls": 500 * 60,
"estimated_tokens_per_call": 4000,
"estimated_cost": round(500 * 60 * 4000 / 1_000_000 * 10.00, 2),
},
"anthropic": {
"model": "claude-sonnet-4-20250514",
"input_cost_per_1M": 3.00,
"output_cost_per_1M": 15.00,
"scenario": "Attacker uses the expensive model for 2 hours",
"estimated_cost": "~$5,000-15,000",
},
"comparison": {
"traditional_api_key_leaked": "Limited damage (rate limits, common usage caps)",
"llm_api_key_leaked": "Direct and immediate financial damage",
"key_difference": "LLM APIs charge per token — unlimited usage = unlimited cost",
},
}
print(json.dumps(cost_analysis, indent=2, ensure_ascii=False))
# Expected output (partial):
# "openai": {
# "estimated_calls": 30000,
# "estimated_cost": 1200.0
# }
Technical comparison: .env vs alternatives
comparison = {
"headers": [
"Feature",
".env + dotenv",
"OS Env Vars",
"Cloud KMS",
"HashiCorp Vault",
],
"rows": [
{
"feature": "Encryption at rest",
"dotenv": "❌ Plain text",
"env_vars": "❌ In memory",
"cloud_kms": "✅ AES-256",
"vault": "✅ AES-256-GCM",
},
{
"feature": "Automatic rotation",
"dotenv": "❌ Manual",
"env_vars": "❌ Manual",
"cloud_kms": "✅ Configurable",
"vault": "✅ Dynamic secrets",
},
{
"feature": "Audit trail",
"dotenv": "❌ None",
"env_vars": "❌ None",
"cloud_kms": "✅ CloudTrail/Audit",
"vault": "✅ Audit backend",
},
{
"feature": "Least privilege",
"dotenv": "❌ All or nothing",
"env_vars": "❌ Per process",
"cloud_kms": "✅ IAM policies",
"vault": "✅ Granular policies",
},
{
"feature": "Versioning",
"dotenv": "❌ No",
"env_vars": "❌ No",
"cloud_kms": "✅ Versions",
"vault": "✅ Versions",
},
{
"feature": "Team scaling",
"dotenv": "❌ Share a file",
"env_vars": "⚠️ Per server",
"cloud_kms": "✅ IAM roles",
"vault": "✅ Auth methods",
},
{
"feature": "Cost",
"dotenv": "✅ Free",
"env_vars": "✅ Free",
"cloud_kms": "⚠️ ~$0.40/secret/month",
"vault": "⚠️ Self-hosted or HCP",
},
{
"feature": "Complexity",
"dotenv": "✅ Minimal",
"env_vars": "✅ Minimal",
"cloud_kms": "⚠️ Moderate",
"vault": "❌ High",
},
{
"feature": "Ideal for",
"dotenv": "Local dev",
"env_vars": "Dev/staging",
"cloud_kms": "Prod (cloud)",
"vault": "Enterprise",
},
],
}
header = " | ".join(comparison["headers"])
separator = " | ".join(["---"] * len(comparison["headers"]))
print(f"| {header} |")
print(f"| {separator} |")
for row in comparison["rows"]:
values = [
row["feature"],
row["dotenv"],
row["env_vars"],
row["cloud_kms"],
row["vault"],
]
print(f"| {' | '.join(values)} |")
12-Factor App: Config as a first principle
The 12-Factor App principles state that configuration should be stored in the environment, not in the code. .env follows the letter but not the spirit:
twelve_factor_compliance = {
"principle": "Store config in the environment",
"dotenv_compliance": {
"letter": True,
"spirit": False,
"explanation": (
".env puts config outside the code (good), "
"but stores it in plain text without protection (bad)"
),
},
"secrets_manager_compliance": {
"letter": True,
"spirit": True,
"explanation": (
"A secrets manager stores config outside the code "
"AND protects it with encryption, rotation, and access control"
),
},
"evolution": [
"Level 0: Hardcoded in code → NEVER in production",
"Level 1: .env with .gitignore → OK for dev",
"Level 2: OS env vars → Better, but no rotation/audit",
"Level 3: Cloud KMS → Production standard",
"Level 4: Vault with dynamic secrets → Enterprise",
],
}
print("Secrets management evolution:")
for level in twelve_factor_compliance["evolution"]:
print(f" {level}")
The transition path: from .env to a secrets manager
You don't need to migrate from .env to Vault overnight. Here's an incremental path that keeps compatibility:
Step 1: Abstraction over the secrets source
import os
from abc import ABC, abstractmethod
from typing import Optional
class SecretsProvider(ABC):
"""Interface to get secrets — independent of the source."""
@abstractmethod
def get(self, key: str) -> Optional[str]:
pass
@abstractmethod
def provider_name(self) -> str:
pass
class EnvSecretsProvider(SecretsProvider):
"""Reads secrets from environment variables / .env."""
def get(self, key: str) -> Optional[str]:
return os.environ.get(key)
def provider_name(self) -> str:
return "environment"
class EncryptedFileProvider(SecretsProvider):
"""Reads secrets from an encrypted file — intermediate step."""
def __init__(self, encryption_key: bytes, filepath: str = ".secrets.enc"):
from cryptography.fernet import Fernet
self.cipher = Fernet(encryption_key)
self.filepath = filepath
self._cache: dict[str, str] = {}
self._load()
def _load(self):
try:
with open(self.filepath, "rb") as f:
encrypted_data = f.read()
decrypted = self.cipher.decrypt(encrypted_data).decode()
for line in decrypted.strip().split("\n"):
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
self._cache[key.strip()] = value.strip()
except FileNotFoundError:
pass
def get(self, key: str) -> Optional[str]:
return self._cache.get(key)
def provider_name(self) -> str:
return "encrypted_file"
class ChainedSecretsProvider(SecretsProvider):
"""Tries multiple providers in order — allows gradual migration."""
def __init__(self, providers: list[SecretsProvider]):
self.providers = providers
def get(self, key: str) -> Optional[str]:
for provider in self.providers:
value = provider.get(key)
if value is not None:
return value
return None
def provider_name(self) -> str:
names = [p.provider_name() for p in self.providers]
return f"chained({', '.join(names)})"
os.environ["OPENAI_API_KEY"] = "sk-from-env"
os.environ["DATABASE_URL"] = "postgresql://from-env"
env_provider = EnvSecretsProvider()
secrets = ChainedSecretsProvider([env_provider])
print(f"Provider: {secrets.provider_name()}")
print(f"OPENAI_API_KEY: {secrets.get('OPENAI_API_KEY')[:10]}...")
print(f"DATABASE_URL: {secrets.get('DATABASE_URL')[:20]}...")
# Expected output:
# Provider: chained(environment)
# OPENAI_API_KEY: sk-from-en...
# DATABASE_URL: postgresql://from-en...
Step 2: Add encryption to the file
from cryptography.fernet import Fernet
import json
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)
secrets_data = "OPENAI_API_KEY=sk-proj-encrypted-key\nDATABASE_URL=postgresql://secure"
encrypted = cipher.encrypt(secrets_data.encode())
with open(".secrets.enc", "wb") as f:
f.write(encrypted)
with open(".secrets.enc", "rb") as f:
raw = f.read()
print(f"Encrypted file: {raw[:50]}...")
decrypted = cipher.decrypt(raw).decode()
print(f"Content: {decrypted}")
# Expected output:
# Encrypted file: gAAAAABn...
# Content: OPENAI_API_KEY=sk-proj-encrypted-key
# DATABASE_URL=postgresql://secure
import os
os.remove(".secrets.enc")
Step 3: Add audit logging
import json
import logging
from datetime import datetime
from functools import wraps
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
audit_logger = logging.getLogger("secrets.audit")
def with_audit(func):
"""Decorator that adds audit logging to secret accesses."""
@wraps(func)
def wrapper(self, key: str, *args, **kwargs):
result = func(self, key, *args, **kwargs)
audit_entry = {
"timestamp": datetime.utcnow().isoformat(),
"action": "secret_read",
"key": key,
"provider": self.provider_name(),
"found": result is not None,
"accessor": kwargs.get("accessor", "unknown"),
}
audit_logger.info(json.dumps(audit_entry))
return result
return wrapper
class AuditedEnvProvider(EnvSecretsProvider):
@with_audit
def get(self, key: str, accessor: str = "unknown") -> Optional[str]:
return super().get(key)
import os
os.environ["TEST_KEY"] = "test-value"
provider = AuditedEnvProvider()
provider.get("TEST_KEY", accessor="api-service")
provider.get("MISSING_KEY", accessor="worker")
# Expected output:
# {"timestamp": "2026-03-13T...", "action": "secret_read", "key": "TEST_KEY", "provider": "environment", "found": true, "accessor": "api-service"}
# {"timestamp": "2026-03-13T...", "action": "secret_read", "key": "MISSING_KEY", "provider": "environment", "found": false, "accessor": "worker"}
The mindset shift: from "file" to "service"
The fundamental change isn't technical — it's a change of mindset:
mindset_comparison = {
"dotenv_mindset": {
"secrets_are": "A file that configures my app",
"access_is": "Reading a file from disk",
"rotation_is": "Edit the file and restart",
"audit_is": "Doesn't exist",
"failure_mode": "If I lose the file, I rebuild it manually",
},
"secrets_manager_mindset": {
"secrets_are": "A service that provides credentials",
"access_is": "An authenticated and audited API call",
"rotation_is": "An automatic, zero-downtime process",
"audit_is": "A complete record of every access",
"failure_mode": "If the service fails, the fallback kicks in",
},
}
print("=== Mindset .env ===")
for k, v in mindset_comparison["dotenv_mindset"].items():
print(f" {k}: {v}")
print("\n=== Mindset Secrets Manager ===")
for k, v in mindset_comparison["secrets_manager_mindset"].items():
print(f" {k}: {v}")
This shift is the central point: stop thinking of secrets as files and start thinking of them as a service. A service that provides credentials on demand, with authentication, authorization, logging, and lifecycle management.
Troubleshooting
"My team says .env works fine"
Ask them: "When was the last time we rotated the API keys?" If the answer is "never" or "I don't know," that's your argument. Show the cost calculation of a leaked LLM key ($1,000+ in minutes) and the pattern of bots scanning GitHub.
"We don't have budget for a secrets manager"
AWS Secrets Manager costs $0.40 per secret per month. With 10 secrets, that's $4/month. Compare that to the cost of a leaked key. Also, many services have a free tier that's enough for startups.
"Migrating is a lot of work"
The ChainedSecretsProvider pattern allows gradual migration: add the secrets manager as the first provider and .env as a fallback. You migrate one secret at a time without breaking anything.
"Docker Compose already handles secrets"
Docker Secrets (Swarm) and Docker Compose secrets are a step up over .env, but they don't have automatic rotation or audit trails. They're a good intermediate step, not a complete solution.
"GitHub Actions has secrets"
Yes, and they're excellent for CI/CD. But they only solve storage within GitHub — your application at runtime still needs a way to obtain the secrets from a secure place. GitHub Secrets complement a secrets manager, they don't replace it.
Exercises
Exercise 1: Audit your current .env
Review the .env file of your AI project and classify each secret by risk level:
See solution
risk_levels = {
"CRITICAL": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DATABASE_URL", "STRIPE_SECRET_KEY"],
"HIGH": ["REDIS_URL", "PINECONE_API_KEY", "AWS_SECRET_ACCESS_KEY"],
"MEDIUM": ["SENTRY_DSN", "SMTP_PASSWORD", "WEBHOOK_SECRET"],
"LOW": ["APP_ENV", "LOG_LEVEL", "PORT"],
}
print("Secrets classification by risk:")
for level, keys in risk_levels.items():
print(f"\n {level}:")
for key in keys:
print(f" - {key}")
print("\nRecommended action:")
print(" CRITICAL → Migrate to a secrets manager immediately")
print(" HIGH → Migrate in the next iteration")
print(" MEDIUM → Evaluate case by case")
print(" LOW → Can stay in env vars (they're not secrets)")
Exercise 2: Implement encryption for your .env
Write a script that encrypts your current .env and reads it back securely:
See solution
from cryptography.fernet import Fernet
import os
def encrypt_env_file(env_path: str, output_path: str) -> bytes:
key = Fernet.generate_key()
cipher = Fernet(key)
with open(env_path, "r") as f:
content = f.read()
encrypted = cipher.encrypt(content.encode())
with open(output_path, "wb") as f:
f.write(encrypted)
print(f"Encrypted {env_path} → {output_path}")
print(f"Encryption key (keep it safe): {key.decode()}")
return key
def read_encrypted_env(encrypted_path: str, key: bytes) -> dict:
cipher = Fernet(key)
with open(encrypted_path, "rb") as f:
encrypted = f.read()
decrypted = cipher.decrypt(encrypted).decode()
secrets = {}
for line in decrypted.strip().split("\n"):
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
secrets[k.strip()] = v.strip()
return secrets
with open(".env.test", "w") as f:
f.write("OPENAI_API_KEY=sk-test-key\nDB_PASS=secret123\n")
key = encrypt_env_file(".env.test", ".env.test.enc")
secrets = read_encrypted_env(".env.test.enc", key)
print(f"Secrets loaded: {list(secrets.keys())}")
os.remove(".env.test")
os.remove(".env.test.enc")
Exercise 3: Build an audit logger for secret accesses
Implement an audit logger that records each secret access with a timestamp, key name, and accessor:
See solution
import json
import logging
from datetime import datetime
from typing import Optional
class SecretAuditLogger:
def __init__(self, log_file: str = "secrets_audit.log"):
self.logger = logging.getLogger("secrets_audit")
self.logger.setLevel(logging.INFO)
handler = logging.FileHandler(log_file)
handler.setFormatter(logging.Formatter("%(message)s"))
self.logger.addHandler(handler)
def log_access(
self,
key: str,
accessor: str,
action: str = "read",
success: bool = True,
):
entry = {
"timestamp": datetime.utcnow().isoformat(),
"action": action,
"key": key,
"accessor": accessor,
"success": success,
}
self.logger.info(json.dumps(entry))
audit = SecretAuditLogger("test_audit.log")
audit.log_access("openai-api-key", "api-service", "read", True)
audit.log_access("database-url", "migration", "read", True)
audit.log_access("admin-password", "unknown-process", "read", False)
with open("test_audit.log") as f:
for line in f:
entry = json.loads(line)
status = "✅" if entry["success"] else "❌"
print(f"{status} {entry['accessor']} → {entry['key']}")
import os
os.remove("test_audit.log")
Exercise 4: Calculate the cost of a leaked key
Write a script that calculates the potential cost of a leaked LLM API key across different attack scenarios:
See solution
def calculate_leak_cost(
model: str,
cost_per_1m_tokens: float,
requests_per_minute: int,
avg_tokens_per_request: int,
hours_before_detection: float,
) -> dict:
total_minutes = hours_before_detection * 60
total_requests = requests_per_minute * total_minutes
total_tokens = total_requests * avg_tokens_per_request
total_cost = (total_tokens / 1_000_000) * cost_per_1m_tokens
return {
"model": model,
"detection_time_hours": hours_before_detection,
"total_requests": int(total_requests),
"total_tokens": int(total_tokens),
"estimated_cost": f"${total_cost:,.2f}",
}
scenarios = [
calculate_leak_cost("gpt-4o", 10.0, 100, 2000, 1),
calculate_leak_cost("gpt-4o", 10.0, 100, 2000, 4),
calculate_leak_cost("gpt-4o", 10.0, 500, 4000, 8),
calculate_leak_cost("claude-sonnet-4-20250514", 15.0, 200, 3000, 2),
]
for s in scenarios:
print(f"{s['model']} ({s['detection_time_hours']}h): {s['estimated_cost']}")
Summary
.envworks for local development but has 5 fatal limitations in production: no encryption, no rotation, no audit trail, no granularity, no team scalability- LLM API keys are high-value targets — a leaked OpenAI key can rack up thousands of dollars in usage in minutes
- The most common incidents are preventable: keys in Git, keys in Docker images, keys in logs, keys shared over Slack
- The technical comparison between
.env, env vars, cloud KMS, and Vault shows that solutions scale with the need — not everyone needs Vault - The transition path is incremental: abstraction over the source → encryption → audit logging → full secrets manager
- The fundamental mindset shift is from "file" to "service": secrets are obtained on demand, with authentication, authorization, and logging
- Capsule 03 shows you the enterprise solution (Vault) and 05 the more accessible cloud options
Next capsule: In capsule 03 you'll meet HashiCorp Vault — the open-source reference for secrets management. You'll see its architecture (secrets engines, auth methods, policies), spin it up in dev mode, and use Python's hvac client to store and retrieve secrets with automatic encryption and audit trails.
Resources
- OWASP Secrets Management Cheat Sheet — OWASP guide with best practices for secrets management in applications
- 12-Factor App — Config — Foundational principle of configuration separated from code
- GitHub Secret Scanning — How GitHub detects secrets exposed in repositories
- IBM Cost of a Data Breach 2024 — Report with real figures on the cost of security breaches
- Python Cryptography — Fernet — Fernet symmetric encryption documentation for local encryption
- GitGuardian State of Secrets Sprawl — Annual report on secrets exposure in public repositories
- Docker Secrets Documentation — Docker Swarm secrets as an intermediate step
- python-dotenv Documentation — python-dotenv documentation to understand its limitations
Created: March 2026 Version: 1.0