Learning Goals
3 minBy the end of this lesson you can:
- Store secrets in the environment (dev
.env, prod secret managers) — never in code. - Keep secrets out of git, logs, error pages, and client-side code.
- Rotate keys safely (overlap old + new) and explain why rotation matters.
- Run the leaked-secret playbook: revoke, rotate, scan history.
Warm-Up · The Key Under the Doormat
5 minYou've seen the rule since Level 7: never hard-code secrets. Here's the stakes — bots continuously scan public GitHub for committed keys and abuse them within minutes. A leaked cloud key has cost people tens of thousands in crypto-mining charges overnight. A secret in code is a key taped to your front door.
Secrets management is about (1) where secrets live — outside code, in the environment or a dedicated manager — (2) keeping them out of every leak channel (git, logs, URLs, client code), (3) rotating them regularly so a stolen key has a short useful life, and (4) responding fast when one leaks. The cheapest secret to protect is one you don't store; the next-best is one that's rotated and revocable.
New Concept · Store, Protect, Rotate, Respond
14 minWhere secrets live
NEVER in source code, in git, in client-side JS, in logs/URLs
DEV a .env file (git-IGNORED) loaded into the environment
PROD the platform's secret store / a dedicated manager:
AWS Secrets Manager, GCP Secret Manager, Azure Key Vault,
HashiCorp Vault, or the host's env vars (set by the platform)import os from dotenv import load_dotenv load_dotenv() # dev: reads .env into the environment API_KEY = os.environ["API_KEY"] # required → crashes loudly if missing DB_PASS = os.environ["DB_PASSWORD"] # the CODE only ever READS secrets from the environment — same code in dev & prod.
The pattern (from Lesson 8) is uniform: code reads from os.environ. In dev, python-dotenv fills it from a git-ignored .env; in prod, the platform/secret manager sets the real values. The code never changes between environments and never contains a secret.
Keep them out of git
# .gitignore (commit THIS) .env *.pem secrets/ # .env.example (commit THIS — same keys, FAKE values, documents what's needed) API_KEY=your-key-here DB_PASSWORD=changeme
If you commit a secret then delete it in a later commit, it's still in the history — anyone who clones the repo can read it. So a committed secret must be treated as compromised: deleting the line is not enough. Add .env to .gitignore before the first commit, and use a pre-commit secret scanner (Lesson 25) to catch slips.
Don't leak via the side channels
- Logs — never log a secret; mask it (
…{key[-4:]}) if you must reference it (Lesson 8, 45). - URLs/query strings — these get logged by servers/proxies; put secrets in headers/body, not the URL.
- Error pages — debug tracebacks leak config (Lesson 34). Generic errors in prod.
- Client-side — anything shipped to the browser/mobile app is public (Lesson 5/25). Secrets stay server-side.
Rotation — limit a stolen key's lifespan
# rotate WITHOUT downtime: accept old + new during an overlap window, # then retire the old. (Like MultiFernet for keys — L8-14.) VALID_KEYS = {os.environ["API_KEY_CURRENT"], os.environ.get("API_KEY_PREVIOUS", "")} def key_is_valid(presented: str) -> bool: import hmac return any(hmac.compare_digest(presented, k) for k in VALID_KEYS if k)
Regular rotation means a secret that leaks is only useful until the next rotation. The trick is overlap: deploy the new key, accept both old and new for a window (so in-flight clients keep working), then remove the old. Rotate signing keys, API keys, DB passwords on a schedule — and immediately on any suspicion.
The leaked-secret playbook
A secret leaked (committed, screenshotted, pasted, logged). Do NOW: 1. REVOKE invalidate it at the source (provider console) — assume it's abused. 2. ROTATE generate a new secret; deploy it (env/secret manager). 3. ASSESS check logs for misuse during the exposure window. 4. CLEAN purge it from git history (BFG/filter-repo) — but step 1 is what matters. 5. PREVENT add a secret scanner to pre-commit/CI so it can't recur. Deleting the line is NOT enough — rotation/revocation is the real fix.
Worked Example · A Secrets-Hygiene Toolkit
12 minGoal: a small toolkit that loads secrets safely, masks them for logging, audits a project for leaked-secret risk, and supports overlap rotation — the practical secrets workflow.
import os, re, logging from pathlib import Path from dotenv import load_dotenv log = logging.getLogger("secrets") def load_secret(name: str) -> str: """Read a required secret from the environment; never default it.""" load_dotenv() value = os.environ.get(name) if not value: raise RuntimeError(f"{name} not set — add it to .env (dev) or the " f"secret manager (prod). Never hard-code it.") return value def masked(secret: str) -> str: """Safe for logs: shows enough to identify, not enough to use.""" return f"{secret[:3]}…{secret[-3:]}" if len(secret) > 8 else "…" def audit_repo(root: str = ".") -> list[str]: """Flag leaked-secret risks: secrets in code, .env not git-ignored.""" issues = [] gitignore = Path(root) / ".gitignore" if not gitignore.exists() or ".env" not in gitignore.read_text(encoding="utf-8"): issues.append("🔴 .env is NOT in .gitignore — secrets could be committed!") patterns = [re.compile(r"sk-[A-Za-z0-9]{16,}"), # API-key-like re.compile(r"AKIA[0-9A-Z]{16}"), # AWS key id re.compile(r"(?i)(password|secret|api_key)\s*=\s*['\"][^'\"]{8,}['\"]")] for py in Path(root).rglob("*.py"): for n, line in enumerate(py.read_text(encoding="utf-8").splitlines(), 1): if any(p.search(line) for p in patterns): issues.append(f"🔴 {py}:{n} possible hard-coded secret") return issues # usage key = load_secret("API_KEY") log.info("loaded API_KEY %s", masked(key)) # logs e.g. "sk-…760", never the key for issue in audit_repo("."): print(issue)
INFO loaded API_KEY sk-…760 ← masked; the real key never hits the log 🔴 .gitignore present, .env ignored ✓ (no issue) 🔴 config_old.py:14 possible hard-coded secret ← caught a slip to fix + rotate
Read the code
The toolkit operationalises the four pillars: load_secret enforces "from the environment, required, never defaulted"; masked makes logging safe (you can confirm which key loaded without exposing it); and audit_repo catches the two leak risks that matter most — a secret hard-coded in source, and a .env that isn't git-ignored. Run the audit in pre-commit/CI and a slip is caught before it's pushed. And if it flags a real committed key, you know the playbook: revoke and rotate first, clean history second.
Try It Yourself
13 minSet up a project with .env (git-ignored), .env.example (committed), and a .gitignore containing .env. Load a secret with load_secret and log it masked. Confirm git status never stages .env.
Run audit_repo on a real project. Fix any hard-coded secret it finds by moving it to .env — and (since git remembers) note that the old value must be rotated, not just deleted.
Implement key validation that accepts both a current and a previous key (constant-time compare). Simulate a rotation: deploy a new key, confirm clients with the old key still work during the overlap, then remove the old and confirm it's rejected.
Hint
import os, hmac def valid(key): accepted = [os.environ["API_KEY_CURRENT"]] prev = os.environ.get("API_KEY_PREVIOUS") if prev: accepted.append(prev) return any(hmac.compare_digest(key, k) for k in accepted) # rotation: set CURRENT=new, PREVIOUS=old → both work; later unset PREVIOUS.
Mini-Challenge · A Pre-Commit Secret Guard
8 minBuild a pre-commit hook (Level 6/7 skills) that scans staged changes for secret patterns and .env being committed, and blocks the commit (exit non-zero) if found. This stops the leak at the source — before it ever reaches history. (Real teams use git-secrets, gitleaks, or detect-secrets; you're building the idea.)
Show a sample solution
#!/usr/bin/env python3 # .git/hooks/pre-commit (chmod +x). Blocks commits containing secrets. import subprocess, re, sys PATTERNS = [re.compile(r"sk-[A-Za-z0-9]{16,}"), re.compile(r"AKIA[0-9A-Z]{16}"), re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")] # names of staged files + their staged content (diff) staged = subprocess.run(["git", "diff", "--cached", "--name-only"], capture_output=True, text=True).stdout.split() if any(f == ".env" or f.endswith(".pem") for f in staged): print("❌ commit blocked: .env / private key is staged — never commit secrets") sys.exit(1) diff = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True).stdout for pat in PATTERNS: if pat.search(diff): print("❌ commit blocked: a secret-like value is in the staged changes.") print(" Move it to .env, rotate the exposed value, and retry.") sys.exit(1) print("✓ no secrets detected in staged changes")
Non-negotiables: scans staged changes, blocks the commit (exit 1) on secret patterns or a staged .env/.pem.
Recap
3 minSecrets live outside code: a git-ignored .env in dev, a secret manager (or platform env) in prod — the code only reads os.environ. Keep secrets out of every leak channel: git (and remember git history is forever — a committed secret is compromised), logs (mask them), URLs, error pages, and client code. Rotate regularly with an old/new overlap so a stolen key has a short life. And know the leaked-secret playbook: revoke and rotate first, then clean history — deleting the line is never enough. Automate detection with a pre-commit/CI secret guard.
Vocabulary Card
- secret manager
- A dedicated store (Vault, AWS/GCP/Azure) for production secrets.
- .env / .env.example
- Git-ignored dev secrets / committed template with fake values.
- key rotation
- Periodically replacing secrets (with an overlap) to limit a leak's impact.
- leaked-secret playbook
- Revoke → rotate → assess → clean → prevent.
Homework
4 minConvert one of your projects to env-only secrets (.env git-ignored, .env.example committed, masked logging), run the repo secret audit and fix any findings, and install the pre-commit secret guard. Write a one-page secrets policy for the project: where secrets live in dev vs. prod, rotation cadence, and the exact steps you'd take the moment a key leaked.
Sample · project secrets policy
SECRETS POLICY
Where secrets live:
Dev: .env (git-ignored). Code reads os.environ via python-dotenv.
.env.example (committed) documents the required keys with FAKE values.
Prod: the platform's secret manager / env vars set by the deploy
pipeline. No secret ever in code, git, logs, URLs, or client JS.
Logging: secrets only ever appear masked (sk-…760). Generic error
pages in prod (no tracebacks).
Rotation: API keys and DB passwords rotated every 90 days, and
IMMEDIATELY on any suspicion. Overlap window: deploy new, accept
old+new for 24h, then retire old (zero downtime).
If a secret leaks (committed/screenshotted/logged):
1. Revoke it at the provider NOW (assume it's abused).
2. Rotate: generate + deploy a new one.
3. Check provider logs for misuse during the exposure window.
4. Purge from git history (filter-repo) — but step 1 is the real fix.
5. Pre-commit secret guard is installed so it can't recur.Non-negotiables: a project converted to env-only secrets, audit fixed, pre-commit guard installed, and a written policy incl. the leak playbook.