Secrets Management for Python Apps
1 · The lesson
readExamples assume AWS / a CI provider / a local
.envfile. Not browser-runnable. Commands shown with the expected output in comments.
The cardinal sin: secrets in git. Public-repo pushes get scraped by credential scanners within minutes — researchers (and worse) maintain bots that watch GitHub's public event stream specifically to harvest leaked AWS keys, Stripe tokens, and OpenAI API keys. By the time you notice your accidental commit, someone has already used the key.
This lesson is about the discipline around production secrets — what counts as one, where they belong (and don't), how to load them at runtime, how to rotate them, and what to do when one leaks. Builds on envconfig (the local-dev pattern) and pairs with security-checklist.
1. What Counts as a Secret
If leaking it would cause harm — financial, data exposure, account takeover — it's a secret. The categories you'll handle:
- Database passwords — give read/write to your entire dataset
- API keys (Anthropic, Stripe, AWS, GitHub) — let attackers run up bills or read your data
- OAuth client secrets — let attackers impersonate your app to providers
- JWT signing secrets — let attackers forge any user's session (link auth-jwt)
- Webhook signing secrets — let attackers send fake webhook events
- Encryption keys — decrypt your at-rest data
- TLS private keys — impersonate your domain
- CI/CD tokens — push code, deploy, read other secrets
Things that aren't secrets but get confused as such:
- Public API keys (Stripe
pk_*, Google Maps client-side keys) — safe in client code - Username / app name / public URLs — not secret
- Schema/structure of your data — not secret on its own
2. The Wrong Ways (and What They Look Like)
# In code — DON'T STRIPE_KEY = "sk_live_51abc..." # In a committed config file — DON'T # config.yaml database: password: "actual_password_here"
# In a Dockerfile — DON'T (cached in layers, visible to anyone with the image) ENV STRIPE_KEY=sk_live_...
# In a public CI config — DON'T env: STRIPE_KEY: "sk_live_..."
The pattern: secrets and code travel separately. If you can grep your repo for the secret value, the secret is compromised.
3. Local Development: .env Files
For your laptop, a gitignored .env file is the standard:
# .env (gitignored) DATABASE_URL=postgresql://localhost/myapp_dev STRIPE_KEY=sk_test_localdevvalue JWT_SECRET=local-dev-only-not-prod
Load it via python-dotenv:
from dotenv import load_dotenv import os load_dotenv() # reads .env into os.environ db_url = os.environ["DATABASE_URL"]
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example")
The companion file you DO commit:
# .env.example (committed) DATABASE_URL=postgresql://localhost/myapp_dev STRIPE_KEY=sk_test_replace_me JWT_SECRET=replace_with_a_real_secret
This documents what env vars your app needs without exposing values. New developers run cp .env.example .env and fill in their own.
4. The Essential .gitignore
# Local secrets .env .env.local .env.*.local # Credentials and keys *.pem *.key *.p12 credentials.json service-account*.json # Config files that hold real values config.local.yaml config.local.toml secrets/
Add this to every Python project on day one. Audit existing repos with git ls-files | grep -E '\.(env|pem|key)$' to catch accidents.
5. Pre-Commit Hook: Catch It Before You Push
detect-secrets (Yelp's tool) scans staged changes for high-entropy strings that look like secrets:
pip install detect-secrets pre-commit
detect-secrets scan > .secrets.baseline
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
pre-commit installNow every git commit runs the scanner first. False positives go in the baseline; real catches stop the commit.
Other tools in the same niche: gitleaks, trufflehog. GitHub also runs its own scanner on every public push — but by that point the secret is already on the wire.
6. Already Committed a Secret? The Recovery Drill
If a secret hits git history, the secret is compromised — full stop. The recovery order matters:
1. Rotate the secret immediately. Generate a new one with the provider. Update everywhere that uses it. The old one is dead.
2. Then scrub history with git filter-repo or BFG:
bash
pip install git-filter-repo
git filter-repo --replace-text <(echo 'OLD_SECRET==>REMOVED')
3. Force-push (coordinate with the team first):
bash
git push --force-with-lease origin main
4. Audit provider logs for unauthorised use of the old secret.
5. Anyone who cloned the repo still has the secret in their local history — tell them to re-clone.
The single most common mistake: trying to scrub history before rotating. Once the secret has hit the public internet (even briefly), it's already in someone's archive. Rotate first; the cleanup is hygiene.
7. Production Secrets: The Managed Options
In production you don't ship a .env file. You inject secrets from a managed store:
| Store | Best for | Auth model |
|---|---|---|
| AWS Secrets Manager | AWS-hosted apps | IAM roles, supports rotation Lambdas |
| AWS Systems Manager Parameter Store | Simple AWS config (cheaper than Secrets Manager) | IAM roles |
| GCP Secret Manager | GCP-hosted apps | Service accounts |
| Azure Key Vault | Azure-hosted apps | Managed identities |
| HashiCorp Vault | Multi-cloud, on-prem, complex policy | Tokens, roles, AppRole |
| Doppler | SaaS, polished DX, multi-env | API tokens |
| 1Password Secrets Automation | Teams already on 1Password | Service accounts |
The right answer is usually "whatever your cloud provider offers natively" — the IAM integration eliminates a class of bootstrap problems (you don't need a secret to fetch your secrets).
Reading from AWS Secrets Manager
import boto3 import json from functools import lru_cache @lru_cache(maxsize=None) def get_secret(name: str) -> dict: client = boto3.client("secretsmanager", region_name="us-east-1") response = client.get_secret_value(SecretId=name) return json.loads(response["SecretString"]) db = get_secret("prod/myapp/database") db_url = f"postgresql://{db['user']}:{db['password']}@{db['host']}/{db['name']}"
The @lru_cache matters — you don't want to hit Secrets Manager on every request. Set a TTL via a custom cache if you want rotation to take effect without a restart (see Section 10).
8. Container Secrets
Three approaches, ranked from best to worst:
Best — Inject at runtime from a secrets manager
# AWS ECS task definition (snippet)
secrets:
- name: DATABASE_URL
valueFrom: arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/db-AbCdEfECS injects the secret as an env var when the container starts. The container image never knows about it.
Acceptable — Kubernetes Secrets with a real backend
kubectl create secret alone stores secrets as base64 (not encryption — base64 is not a security mechanism). Pair with:
- AWS EKS + External Secrets Operator → pulls from Secrets Manager
- Google GKE + Workload Identity → pulls from Secret Manager
- Sealed Secrets / SOPS for GitOps workflows
Avoid — Secrets baked into the image
Anything in ENV STRIPE_KEY=... lives in image layers forever and is visible to anyone who can docker pull. Even private registries leak via compromised CI tokens.
9. CI/CD Secrets
GitHub Actions: Repo settings → Secrets and variables → Actions (or Environment-scoped for prod-only secrets):
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
AWS_DEPLOY_ROLE: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
run: python deploy.pyModern best practice for cloud deploys: OIDC, not long-lived keys. GitHub Actions can request short-lived AWS credentials via OIDC, so you never store an AWS_SECRET_ACCESS_KEY in CI:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1GitHub authenticates to AWS as itself; AWS issues a 1-hour credential. No long-lived secret to leak.
10. Rotation: The Discipline You'll Skip and Regret
The principle: every long-lived secret should rotate on a schedule. Recommended cadences:
- High value (root keys, signing keys, prod DB roots): 30–90 days
- Application secrets (API keys, OAuth client secrets): 90–180 days
- On personnel change: rotate everything that person had access to, same day
The zero-downtime rotation pattern:
1. Generate new secret alongside the old one (both valid)
2. Update apps to read the new secret (rolling deploy)
3. Wait one full deploy cycle to confirm
4. Revoke the old secret
Most managed stores support this natively — Secrets Manager rotation Lambdas, Vault dynamic credentials. Use them.
For app-level cache invalidation when a secret rotates without a restart:
import time, threading class CachedSecret: def __init__(self, name, ttl_seconds=300): self._name = name self._ttl = ttl_seconds self._value = None self._fetched_at = 0 self._lock = threading.Lock() def get(self) -> dict: # Avoid stampedes — only one fetch in flight at a time. if time.time() - self._fetched_at > self._ttl: with self._lock: if time.time() - self._fetched_at > self._ttl: # double-check inside lock self._value = _fetch_from_aws(self._name) self._fetched_at = time.time() return self._value
setup added so this can run · defines _fetch_from_aws
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def _fetch_from_aws(*_a, **_kw): print('-> _fetch_from_aws() called') return _AutoMock('_fetch_from_aws()')
11. Logging Safely
A surprising amount of secrets leak via logs:
# DON'T logger.info(f"Config loaded: {config}") # dumps the whole dict, secrets and all logger.error(f"Auth failed for token {token}") # logs the token # DO logger.info("Config loaded", extra={"keys": list(config.keys())}) logger.error("Auth failed", extra={"token_prefix": token[:4] + "***"})
setup added so this can run · defines logger, config, token
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logger = _AutoMock('logger') config = _AutoMock('config') token = ["alpha", "beta", "gamma"]
For structured logging, configure redaction at the formatter level. structlog has built-in redactors; python-json-logger accepts a filter callback. The principle: assume every log line gets shipped to Datadog/Splunk/wherever and read by anyone with dashboard access.
Mask helper:
def mask_secret(s: str, show: int = 4) -> str: if not s or len(s) <= show: return "***" return f"{s[:show]}***" logger.info("Stripe key loaded", extra={"key": mask_secret(stripe_key)}) # → Stripe key loaded {"key": "sk_l***"}
setup added so this can run · defines logger, stripe_key
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logger = _AutoMock('logger') stripe_key = _AutoMock('stripe_key')
12. Encryption at Rest (for the Data Itself)
Secrets manager protects the keys. For sensitive data (PII, payment details, health records) you also need to encrypt the data with those keys.
cryptography library, Fernet for symmetric encryption:
from cryptography.fernet import Fernet # Key from secrets manager — NEVER in code key = get_secret("prod/myapp/data-key")["fernet_key"].encode() f = Fernet(key) token = f.encrypt(b"user's social security number") plain = f.decrypt(token)
setup added so this can run · defines get_secret
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def get_secret(*_a, **_kw): print('-> get_secret() called') return _AutoMock('get_secret()')
Store ciphertext in your DB; keep the key in the secrets manager; rotate the key with re-encryption (or use AWS KMS envelope encryption for big datasets).
13. Audit and Least Privilege
- Every secret should have a documented owner — who's responsible for rotation, who knows when it leaks.
- IAM policies: grant access to the specific secret your app needs, not
secretsmanager:*. - Audit logs: enable CloudTrail / Vault audit log; review who's reading what.
- On offboarding: rotate every secret the departing person had access to. Same day, not "we'll get to it".
- Periodic dependency audit:
pip install pip-audit && pip-audit— vulnerable libraries can leak secrets through bugs you don't control.
Common Mistakes
- Scrubbing git history before rotating the secret. Order matters: rotate first, scrub second. The history scrub is hygiene; rotation is the actual fix.
.envnot gitignored. Audit any project on day one withgit check-ignore .env. If it doesn't return.env, you have a problem.- Logging the whole config dict for "debugging". Use a
mask_secret()helper and redact at the formatter level. - Reusing the same secret across environments. Prod and staging should have different DB passwords, JWT signing keys, OAuth secrets. Otherwise a staging leak gives away prod.
- Storing the secrets-manager auth token in the same place as your secrets. The chicken-and-egg problem solved by IAM roles / workload identity — the infrastructure authenticates, not a secret.
- Assuming "private repo" = secret-safe. Compromised dev machines, contractor access, GitHub data breaches — private repos leak too. The actual defence is "secret never enters the repo".
- Long-lived CI tokens with no expiry. Use OIDC where supported; rotate the rest quarterly.
pip installfrom typos. Typosquatting attacks publish malicious packages with near-correct names (requestesvsrequests). They steal env vars on import. Mitigate withpip-audit, pinning, and lockfiles.
🎯 Your Turn — Environment-Aware Secrets Loader
Build a Secrets class that:
- Reads from AWS Secrets Manager when
APP_ENV=production(orstaging) - Reads from
.envlocally otherwise - Caches results with a configurable TTL (so rotated secrets are picked up without a restart, but you don't hammer the API)
- Exposes
get(key, default=None)for a single field - Masks values in
__repr__so logging the object doesn't leak
import os import time from typing import Any class Secrets: """Environment-aware secrets loader.""" def __init__(self, secret_name: str, ttl_seconds: int = 300): # TODO 1: store the secret name + ttl, init the cache fields ... def get(self, key: str, default: Any = None) -> Any: # TODO 2: refresh the cache if expired, return the requested key ... def _load(self) -> dict: # TODO 3: switch on os.environ["APP_ENV"] # - "production" / "staging" → fetch from boto3 secretsmanager # - else → read from os.environ (dotenv-loaded) ... def __repr__(self) -> str: # TODO 4: return "<Secrets name=... loaded=... keys=[...]>" # MUST NOT include values ... # Demo (with mocked boto3 — in real use, boto3 hits AWS): os.environ["APP_ENV"] = "development" os.environ["DATABASE_URL"] = "postgresql://localhost/dev" os.environ["STRIPE_KEY"] = "sk_test_abc" s = Secrets("prod/myapp/main") print(s.get("DATABASE_URL")) # → postgresql://localhost/dev print(repr(s)) # → <Secrets name=prod/myapp/main loaded=2026-... keys=['DATABASE_URL', 'STRIPE_KEY']>
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("APP_ENV", "example-app-env") os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example") os.environ.setdefault("STRIPE_KEY", "example-stripe-key")
Hint 1 — Caching with a TTL
Store_value: dict | None = None and _fetched_at: float = 0. In get(), refresh if time.time() - self._fetched_at > self._ttl. A real impl would also use a lock to avoid the thundering-herd problem on cache miss.
Hint 2 — Environment switching
env = os.environ.get("APP_ENV", "development"); then if env in ("production", "staging"): use boto3.client("secretsmanager").get_secret_value(SecretId=...)["SecretString"] and json.loads it. Otherwise return dict(os.environ) (or just the keys you care about).
Show full solution
import os import json import time from datetime import datetime from typing import Any class Secrets: """Environment-aware secrets loader with TTL cache and safe repr.""" def __init__(self, secret_name: str, ttl_seconds: int = 300): self._name = secret_name self._ttl = ttl_seconds self._value: dict | None = None self._fetched_at: float = 0.0 def get(self, key: str, default: Any = None) -> Any: if self._value is None or (time.time() - self._fetched_at) > self._ttl: self._value = self._load() self._fetched_at = time.time() return self._value.get(key, default) def _load(self) -> dict: env = os.environ.get("APP_ENV", "development") if env in ("production", "staging"): # Production / staging: fetch from AWS Secrets Manager. import boto3 # imported lazily so dev doesn't need boto3 installed client = boto3.client("secretsmanager") response = client.get_secret_value(SecretId=self._name) return json.loads(response["SecretString"]) # Dev / test: read directly from process env (dotenv-loaded by the app). return dict(os.environ) def __repr__(self) -> str: loaded = ( datetime.fromtimestamp(self._fetched_at).isoformat(timespec="seconds") if self._fetched_at else "never" ) keys = list(self._value.keys()) if self._value else [] # Critical: never include values in repr — logs are read by many. return f"<Secrets name={self._name} loaded={loaded} keys={keys}>"
setup added so this can run · defines
import os # noqa: F401 os.environ.setdefault("APP_ENV", "example-app-env")
Two design notes worth calling out:
1. Lazy import boto3 — keeps the local-dev path from requiring pip install boto3. The import only happens when APP_ENV is staging/prod, which is the only context where boto3 is needed anyway.
2. __repr__ only emits keys, never values. This is the load-bearing safety net: anyone who accidentally logs the object gets metadata, not credentials.
In production you'd add: a lock around _load to prevent stampedes on cache expiry; metrics for fetch latency and cache-hit ratio; a fallback to the previous cached value when AWS is unreachable (graceful degradation).
What You Learned
- The seven categories of real secrets — and what looks like a secret but isn't.
- Local dev uses
.env+python-dotenv; production uses a managed secrets store with IAM-based auth. - Three layers of defence against committed secrets:
.gitignore, pre-commit hooks (detect-secrets), and a recovery drill if one slips through. - Recovery order: rotate first, scrub history second. The secret is already compromised the moment it hits git.
- OIDC eliminates the "long-lived credential in CI" problem for cloud deploys.
- Rotation is a discipline, not a one-off; managed stores support zero-downtime patterns natively.
- Logs are the silent leak channel — mask with a
mask_secret()helper and configure formatter-level redaction. - Encryption at rest needs a key — and that key lives in the secrets manager, not next to the data.
Next: this is the last lesson in the DevOps & Deploy track. From here, head back to the Web Frameworks track to wire the auth + secrets patterns into a real Flask or FastAPI app, or move on to Auth & Security for the application-layer side of the same problem.