How should you manage secrets and configuration in a Python application?
Quick Answer
Never hardcode secrets (API keys, database passwords, tokens) in source code or commit them to version control — load them from **environment variables** (via `os.environ`, `python-dotenv` for local development) or a dedicated **secrets manager** (AWS Secrets Manager, HashiCorp Vault, environment injection from your deployment platform) at runtime, and keep non-secret configuration separate (a `.env`/config file that's safe to commit, or environment-specific settings modules) from actual credentials.
Detailed Answer
The mistake: hardcoded secrets in source
# DON'T -- committed to git, visible in history forever, even if later "removed"
DATABASE_PASSWORD = "hunter2"
API_KEY = "sk-live-abc123..."
Once a secret is committed to version control, it's in the repository's history permanently (removing it from the latest commit doesn't remove it from history) — anyone with read access to the repo, now or in the future, can find it. This is one of the most common real-world causes of security incidents.
Loading from environment variables
import os
DATABASE_PASSWORD = os.environ["DATABASE_PASSWORD"] # raises KeyError if missing -- fail loudly
API_KEY = os.environ.get("API_KEY") # or provide a fallback if optional
Environment variables keep secrets out of the codebase entirely — they're injected at deploy/runtime by the hosting platform, CI secrets store, or orchestration system (Kubernetes secrets, systemd environment files), and never touch the repository.
Local development: .env files (never committed)
# .env (in .gitignore -- never committed!)
DATABASE_PASSWORD=local-dev-password
API_KEY=sk-test-...
from dotenv import load_dotenv
load_dotenv() # reads .env into os.environ, for local dev convenience
import os
password = os.environ["DATABASE_PASSWORD"]
python-dotenv loads a local .env file into the process environment,
giving the same os.environ access pattern locally as in
production — critically, .env must be listed in .gitignore, and a
.env.example (with placeholder, non-real values) is committed instead
to document what variables are needed.
Dedicated secrets managers for production
import boto3
client = boto3.client("secretsmanager")
secret = client.get_secret_value(SecretId="prod/db-password")["SecretString"]
For production systems, a dedicated secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) adds capabilities plain environment variables don't offer: access auditing (who fetched which secret, when), automatic rotation, and fine-grained access control per service — worth the added complexity for anything beyond small applications.
Separating secrets from non-secret configuration
# settings.py -- safe to commit; no actual secrets here
import os
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
DATABASE_HOST = os.environ.get("DATABASE_HOST", "localhost")
DATABASE_PASSWORD = os.environ["DATABASE_PASSWORD"] # the actual secret, injected at runtime
Non-sensitive configuration (feature flags, hostnames, timeouts) can reasonably live in a committed settings file with sensible defaults; only the genuinely sensitive values need to come exclusively from the environment/secrets manager with no committed default at all.
A useful checklist
- Add
.env,*.pem,credentials.json, etc. to.gitignorefrom day one. - Use pre-commit secret-scanning hooks (
detect-secrets,gitleaks) to catch accidental commits before they happen. - Rotate any secret that was ever accidentally committed — removing it from the latest commit is not sufficient; treat it as compromised.
Interview-ready summary: Secrets belong in environment variables or a
dedicated secrets manager, injected at runtime — never hardcoded in
source or committed to version control, since git history is effectively
permanent. Use .env files (gitignored) for local development
convenience, and treat any secret that was ever committed as compromised
and due for rotation.