PythonMastery
intermediate 22 min read · lesson 4 of 4 in DevOps & Deploy

Secrets Management for Python Apps

1 · The lesson

read

Examples assume AWS / a CI provider / a local .env file. 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)

python
# In code — DON'T
STRIPE_KEY = "sk_live_51abc..."

# In a committed config file — DON'T
# config.yaml
database:
  password: "actual_password_here"
dockerfile
# In a Dockerfile — DON'T (cached in layers, visible to anyone with the image)
ENV STRIPE_KEY=sk_live_...
yaml
# 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:

bash
# .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:

python
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:

bash
# .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

python
# 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:

bash
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 install

Now 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:

StoreBest forAuth model
AWS Secrets ManagerAWS-hosted appsIAM roles, supports rotation Lambdas
AWS Systems Manager Parameter StoreSimple AWS config (cheaper than Secrets Manager)IAM roles
GCP Secret ManagerGCP-hosted appsService accounts
Azure Key VaultAzure-hosted appsManaged identities
HashiCorp VaultMulti-cloud, on-prem, complex policyTokens, roles, AppRole
DopplerSaaS, polished DX, multi-envAPI tokens
1Password Secrets AutomationTeams already on 1PasswordService 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

python
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

yaml
# AWS ECS task definition (snippet)
secrets:
  - name: DATABASE_URL
    valueFrom: arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/db-AbCdEf

ECS 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):

yaml
# .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.py

Modern 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:

yaml
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
    aws-region: us-east-1

GitHub 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:

python
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:

python
# 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:

python
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:

python
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.
  • .env not gitignored. Audit any project on day one with git 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 install from typos. Typosquatting attacks publish malicious packages with near-correct names (requestes vs requests). They steal env vars on import. Mitigate with pip-audit, pinning, and lockfiles.

🎯 Your Turn — Environment-Aware Secrets Loader

Build a Secrets class that:


  • Reads from AWS Secrets Manager when APP_ENV=production (or staging)

  • Reads from .env locally 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

python
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
python
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.