Managing Secrets#
Secrets — API keys, database passwords, tokens, private keys — must never live in source code or version control. Python applications should load credentials from environment variables, secret managers, or encrypted stores at runtime, rotate them regularly, and avoid leaking them in logs, tracebacks, or error messages.
How to use this page
Treat secrets as configuration, not code. Pair with File IO for permission-safe files, HTTP Requests for authenticated clients, and Unit Testing for mocking credentials in tests.
At a glance
| Track | Python Advanced → Security and Safe Practices |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: Golden rules · Environment variables · .env files with python-dotenv · secrets module — cryptographic randomness · File-based secrets with strict permissions · keyring — OS credential store · Cloud secret managers (pattern) · Configuration layering · … (+5 more)
- Golden rules
- Environment variables
.envfiles withpython-dotenvsecretsmodule — cryptographic randomness- File-based secrets with strict permissions
keyring— OS credential store- Cloud secret managers (pattern)
- Configuration layering
- HTTP clients and secrets
- Logging and error hygiene
- Testing without real secrets
- Git hygiene … and 1 more sections
Golden rules#
| Rule | Rationale |
|---|---|
| Never commit secrets to git | History is forever — bots scan public repos |
| Never log secrets | Logs aggregate to insecure systems |
| Least privilege | Scope tokens to minimum permissions |
| Rotate on exposure | Assume compromise after any leak |
| Separate dev/prod secrets | Staging keys should not unlock production |
| Fail fast if missing | Crash at startup, not mid-request |
Environment variables#
import os
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
api_key = require_env("API_KEY")
database_url = require_env("DATABASE_URL")
| Pros | Cons |
|---|---|
| Simple, 12-factor friendly | Visible in process list on some OS |
| No files to manage | Easy to misconfigure in shells |
| Works in containers/K8s | No built-in rotation |
In Docker/Kubernetes, inject via secrets mounted as env vars or files.
.env files with python-dotenv#
For local development only — not a production secret store.
# pip install python-dotenv
from dotenv import load_dotenv
import os
load_dotenv() # loads .env from cwd or specified path
api_key = os.environ["API_KEY"]
.gitignore:
Provide .env.example with placeholder keys:
# .env.example — safe to commit
API_KEY=your-key-here
DATABASE_URL=postgresql://user:pass@localhost/dbname
Interview trap — .env in production
Production should use vault/K8s secrets/AWS SM — not a .env file on disk in the image.
secrets module — cryptographic randomness#
import secrets
# NOT for loading API keys — for GENERATING tokens
token = secrets.token_urlsafe(32)
password = secrets.token_hex(16)
# Compare secrets in constant time
import hmac
hmac.compare_digest(provided_token, expected_token)
| Function | Use |
|---|---|
token_urlsafe(n) |
Session tokens, reset links |
token_hex(n) |
Hex-encoded random bytes |
compare_digest(a, b) |
Timing-safe string comparison |
Do not use random.random() for security-sensitive tokens.
File-based secrets with strict permissions#
import os
from pathlib import Path
def read_secret_file(path: Path) -> str:
if oct(path.stat().st_mode & 0o777) != "0o600":
raise PermissionError(f"Secret file {path} must be mode 600")
return path.read_text(encoding="utf-8").strip()
key = read_secret_file(Path("/run/secrets/api_key"))
os.chmod(path, 0o600) # set after writing
Docker and Kubernetes mount secrets as files under /run/secrets/ — read at startup, keep in memory only.
See File IO for atomic writes when generating local key material.
keyring — OS credential store#
import keyring
keyring.set_password("myapp", "api_user", "secret-value")
password = keyring.get_password("myapp", "api_user")
Uses macOS Keychain, Windows Credential Locker, or Secret Service on Linux. Good for CLI tools on developer machines — less common in server deployments.
Cloud secret managers (pattern)#
# Conceptual — AWS Secrets Manager via boto3
import boto3
import json
def load_db_credentials(secret_arn: str) -> dict:
client = boto3.client("secretsmanager")
response = client.get_secret_value(SecretId=secret_arn)
return json.loads(response["SecretString"])
| Provider | Service |
|---|---|
| AWS | Secrets Manager, SSM Parameter Store |
| GCP | Secret Manager |
| Azure | Key Vault |
| HashiCorp | Vault |
Fetch once at startup, cache in memory, refresh on rotation signals.
Configuration layering#
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Settings:
api_key: str
database_url: str
debug: bool
def load_settings() -> Settings:
return Settings(
api_key=os.environ["API_KEY"],
database_url=os.environ["DATABASE_URL"],
debug=os.environ.get("DEBUG", "false").lower() == "true",
)
| Layer | Source |
|---|---|
| Defaults | Safe dev defaults in code (non-secret) |
| Environment | Production overrides |
Local .env |
Developer machines only |
| Secret manager | Production credentials |
Validate all required fields before serving traffic — fail at import/startup.
HTTP clients and secrets#
import os
import httpx
def create_client() -> httpx.Client:
token = os.environ["API_TOKEN"]
return httpx.Client(
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
Never embed tokens in URLs (logged in access logs). Use headers. Details: HTTP Requests.
Logging and error hygiene#
import logging
logger = logging.getLogger(__name__)
def safe_log_request(url: str, headers: dict) -> None:
redacted = {k: "***" if k.lower() == "authorization" else v for k, v in headers.items()}
logger.info("request url=%s headers=%s", url, redacted)
| Leak vector | Prevention |
|---|---|
print(os.environ) |
Strip in code review |
| Exception messages with URL query tokens | Parse and redact |
| Debug middleware dumping headers | Disable in prod |
| Jupyter notebooks | Clear outputs before commit |
Testing without real secrets#
import pytest
@pytest.fixture(autouse=True)
def fake_secrets(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key-not-real")
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
Use dedicated test credentials with limited scope — never production keys in CI. See Unit Testing.
Git hygiene#
If a secret is committed:
- Rotate the credential immediately
- Remove from history (
git filter-repoor BFG) — coordinate with team - Audit access logs for misuse
Pre-commit hooks (detect-secrets, gitleaks) catch accidents early.
CLI tools and secrets#
Prompt interactively for local admin tools — avoid echoing to terminal. For automation, use env vars. See Building Command Line Interfaces.
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
| API key in source code | Leaked via git | Environment / vault |
.env committed |
Public exposure | .gitignore + .env.example |
random() for session tokens |
Predictable tokens | secrets.token_urlsafe |
| Logging full request headers | Token in log aggregator | Redact sensitive headers |
| Same prod key in dev | Blast radius on leak | Separate credentials |
os.environ dump in debug |
Accidental exfiltration | Structured debug flags |
World-readable key file (chmod 644) |
Local privilege escalation | 0o600 permissions |
| Secrets in URL query strings | Logged by proxies | Authorization header |
Mental model checklist#
- Where should production database passwords live?
- What is the purpose of
secrets.compare_digest? - Why is
.envinappropriate as a production secret backend? - How do you fail fast when
API_KEYis missing? - What file mode should a local PEM key use?
- How do you test code that reads
os.environ?
What's next#
| Topic | Page |
|---|---|
| Secure file reads | File IO |
| Authenticated HTTP | HTTP Requests |
| Mocking in tests | Unit Testing |
| CLI password prompts | Building Command Line Interfaces |