Every system you'll ever touch as an FDE runs on secrets — API keys, passwords, tokens. Handle them wrong once and you'll learn why the hard way. This is the short, practical guide to never being that story.

Prereq: comfort with the terminal and Git basics from The Terminal and Git, Demystified. No security background needed.

Monday, 9:12 AM. Your CityOps 311 pipeline finally works end to end — it pulls live complaint data from the city's open data portal and lands it in your database. You're proud. You push the repo to GitHub so your teammate can review it, grab coffee, and come back to a Slack message from a stranger:

"Hey — your repo has a live API key in pull_311.py. Bots scan GitHub for these within minutes. You should revoke it."

Your stomach drops. The key is the city's production data key, issued to your team. It's now public. Anyone on the internet can burn through its quota, or worse, and the city will revoke it — taking your pipeline down with it.

This post exists so that Monday never happens to you.

Clarify the ask: what counts as a secret?

Before fixing anything, get precise about what you're protecting. A secret is any string that proves you are allowed to do something: read private data, write to a system, spend someone's money. If someone else learns it, they can impersonate you.

Secret — guard itNot a secret — safe in code
API keys and tokensPublic API endpoint URLs
Database passwords and connection stringsDatabase hostnames (without credentials)
Private SSH keysPublic SSH keys (*.pub)
OAuth client secretsOAuth client IDs (usually)
Signing keys, encryption keysTimeouts, retry counts, feature flags

The rule of thumb: if leaking it would let a stranger act as you, it's a secret. When in doubt, treat it as a secret — over-protecting a timeout value costs nothing; under-protecting a key costs everything.

The minimum concept: secrets are config, not code

Here is the entire mental model this post hangs on:

The one principle

  • A secret in code is a secret already shared. Code gets copied, screenshotted, pasted into chat, committed, pushed, and backed up. Every one of those is a place your secret now lives that you don't control.
  • Secrets are configuration, not code. Code describes what to do; secrets describe who is allowed to do it. They change for different reasons, on different schedules, and they belong in different places.
  • So: code goes in Git. Secrets go in the environment. Your program reads them at runtime and never contains them.
flowchart TD A[Your Python code] -->|reads at runtime| B[Environment variables] C[.env file] -->|gitignored — local dev only| B D[Secret manager] -->|teams & production| B A -.->|NEVER put secrets here| E[Git repo / GitHub] E -.->|If a secret lands here, assume leaked| F[Revoke & rotate immediately]

Build it: the right way, step by step

Let's wire the CityOps 311 pull script the right way. First, the lazy way — so you can see exactly what's wrong with it:

# pull_311.py -- THE LAZY WAY (don't do this)
API_KEY = "cityops_live_9f3a2b7c4d1e"

def fetch_complaints():
    print(f"Fetching 311 complaints with key ...{API_KEY[-4:]}")

fetch_complaints()
$ python3 pull_311.py
Fetching 311 complaints with key ...4d1e

It works. That's the trap — the lazy way always works right up until it destroys you. The key is sitting in plain text, and anyone who can read the file owns it:

$ grep -rn "cityops_live" .
./pull_311.py:2:API_KEY = "cityops_live_9f3a2b7c4d1e"

Now the right way. The script reads the key from the environment — per-process variables the OS hands to your program — and refuses to run without it:

# pull_311.py -- THE RIGHT WAY
import os

api_key = os.environ.get("CITYOPS_API_KEY")
if not api_key:
    raise SystemExit(
        "CITYOPS_API_KEY is not set.\n"
        "Copy .env.example to .env, add your key, then load it:\n"
        "  set -a; source .env; set +a"
    )

print(f"Fetching 311 complaints with key ...{api_key[-4:]}")

Notice there's no try/except here — and that's deliberate. A missing secret isn't something to recover from; it's a configuration error the human must fix. Failing fast with a message that says exactly what to do is kinder than crashing ten lines later with a confusing authentication error. (That's the standing rule: only catch an exception when you can add useful context or recover from it.)

$ CITYOPS_API_KEY=demo_key_abc123 python3 pull_311.py
Fetching 311 complaints with key ...c123
$ python3 pull_311.py
CITYOPS_API_KEY is not set.
Copy .env.example to .env, add your key, then load it:
  set -a; source .env; set +a

Two files make this work in a real project. First, .env — your personal secrets, one per line, never committed:

# .env -- YOUR secrets. Never commit this file.
CITYOPS_API_KEY=cityops_live_9f3a2b7c4d1e

Second, .gitignore — the bouncer that keeps .env out of Git. (Gitignore patterns were covered in The Terminal and Git, Demystified — this is where that lesson pays for itself.)

# .gitignore
.env
__pycache__/
*.pyc
$ git status --short
?? .gitignore

See that? Git sees .gitignore but not .env. Your secrets are invisible to version control. And third, .env.example — a committed template with fake values showing teammates what they need:

# .env.example -- commit this. It shows WHAT is needed, not the values.
CITYOPS_API_KEY=your_key_here

The three-file pattern — memorize it

  • .env — real secrets. Gitignored, never committed.
  • .gitignore — lists .env so Git can't see it.
  • .env.example — committed template with placeholder values, so the next person knows what to fill in.

Break it: why "just delete it" doesn't work

Suppose the lazy way already happened — the key got committed. You delete the line and commit the fix. Safe now? Let's check:

$ git log --all -p -- config.py | grep -c "cityops_live_9f3a2b7c4d1e"
2

The key appears twice in history even though the current file is clean. Git never forgets — every clone, every fork, every backup of that repo still contains the key. A committed secret is a leaked secret, permanently. There is no "oops" button; there is only the revoke playbook:

When a secret leaks — do this immediately

  • Revoke it. Go to the provider's dashboard and kill the key now. Don't clean up first; a live leaked key is being abused while you tidy.
  • Rotate it. Issue a fresh key and update every place the old one was used — your .env, the server, your teammate's machine.
  • Check for abuse. Look at the provider's usage logs for the window the key was exposed. Unusual spikes mean someone found it.
  • Tell the customer honestly. What leaked, when, what you did, and what you're changing so it doesn't recur. (More on this below.)

Productionize: least privilege and secret managers

The .env pattern covers you on your own machine. Two more ideas carry you into real client work.

Least privilege: keys should be weak on purpose

Least privilege means every secret gets the minimum power it needs — nothing more. When the city issues your 311 key, ask for read-only access to the complaints dataset, not admin access to the whole portal. Then, when (not if) a key leaks, the blast radius is a bruise instead of a crater. A read-only key that leaks is an incident; an admin key that leaks is a disaster.

Secret managers: the team-scale answer

.env files don't scale to teams — you can't Slack secrets around (that's just leaking with extra steps). For shared and production systems, teams use a secret manager: a locked vault (like HashiCorp Vault, AWS Secrets Manager, or Doppler) where secrets live encrypted, access is logged, and rotation is a button press instead of a scavenger hunt. You don't need to operate one today — just know the shape of the answer: local dev → .env; teams and production → a secret manager.

Later, not now

  • Running your own Vault cluster, key rotation automation, short-lived dynamic credentials — real skills, but they're Stage 5 (Deploy) territory.
  • Today: the three-file pattern, least privilege, and the revoke playbook. That's 95% of the protection for 5% of the effort.

Communicate: telling the customer a key leaked

This is the part nobody teaches and everybody needs. If a secret leaks on your watch, the customer hears it from you — fast, specific, and without minimization:

"Lisa — heads up on something I need to own. This morning I found that the CityOps 311 API key was committed to our repo in plain text for about three hours before I caught it. I've already revoked the key and issued a new one, so the old key is dead. I checked the portal's usage logs for that window and saw no unusual activity. The pipeline is running on the new key now. Here's what I'm changing so it can't happen again: secrets move to environment variables with a .env + .gitignore pattern starting today, and I'm adding a pre-commit check that scans for key patterns. Happy to walk through any of this — and I'm sorry this happened."

Note the shape: what happened, what you already did, what the impact was, what changes next. No excuses, no jargon, no "it was just a dev key." Customers forgive mistakes; they don't forgive cover-ups.

CityOps: wire it into the project

Back to the milestone. Your CityOps scaffold from Milestone 1: The CityOps Scaffold now gets its secrets discipline:

cityops/
├── .env              # real key -- gitignored, on your machine only
├── .env.example      # CITYOPS_API_KEY=your_key_here -- committed
├── .gitignore        # contains: .env
└── scripts/
    └── pull_311.py   # reads os.environ, fails fast if unset

Every future CityOps script that needs a credential follows the same three-file pattern. When a teammate joins, they copy .env.example to .env, paste their own key, and everything works — no secrets in chat, no secrets in Git, no Monday-morning Slack messages from strangers.

Field check

  1. You find DB_PASSWORD="s3cret!" hardcoded in a client's legacy script that five people share over email. What's your first move, and what do you set up so it stays fixed?
  2. A teammate says ".env is annoying — let's just put the staging key in the repo, it's only staging." How do you respond?
  3. Lisa asks: "If our 311 API key leaked yesterday, how would we know, and what would you do in the first 30 minutes?" Draft the reply.
What a good answer looks like

1. First move: rotate that password immediately — it's been emailed around, so assume it's compromised. Then move it to an environment variable, add the three-file pattern (.env + .gitignore + .env.example), and get everyone off the emailed copy. To keep it fixed: the committed .env.example documents what's needed, and a note in the repo README explains the pattern so the next person doesn't regress.

2. Staging keys leak into production more often than anyone admits — configs get copied, repos get made public, contractors keep clones. The habit has to be unconditional: no secrets in repos, ever. The three-file pattern costs thirty seconds; a leaked staging key that turns out to also work on production costs a career story.

3. How we'd know: the provider's usage dashboard — unexpected quota burn or calls from unfamiliar IPs during the exposure window. First 30 minutes: revoke the key, issue a new one, update .env everywhere it's used, check usage logs for abuse, then tell Lisa what happened and what's changing. Honest, specific, already handled before the email is sent.