We’ve all done it. That API_KEY = "sk-..." sitting right there in the code. “I’ll fix it later,” we tell ourselves.

Later never comes. Until the key ends up on GitHub and someone mines crypto on your AWS account.

Here’s how to do it right.

The Evolution of Secrets Management

Level 0: Hardcoded (Don’t)

1
2
3
# Please no
DATABASE_URL = "postgresql://admin:supersecret@prod-db.example.com/app"
STRIPE_KEY = "sk_live_abc123..."

Problems:

  • Secrets in version control
  • Same secrets in dev/staging/prod
  • No audit trail
  • Rotation requires code changes

Level 1: Environment Variables

Better. Secrets live outside the code.

1
2
3
4
import os

DATABASE_URL = os.environ["DATABASE_URL"]
STRIPE_KEY = os.environ["STRIPE_KEY"]
1
2
3
# .env (never commit this)
DATABASE_URL=postgresql://admin:supersecret@prod-db.example.com/app
STRIPE_KEY=sk_live_abc123
1
2
3
# Load in shell
export $(cat .env | xargs)
python app.py

Or use python-dotenv:

1
2
3
4
from dotenv import load_dotenv
load_dotenv()

DATABASE_URL = os.environ["DATABASE_URL"]

Add to .gitignore:

..!ee.nnevvn.v*.example

Level 2: Platform Secrets

Cloud platforms have built-in secrets management.

AWS Parameter Store:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import boto3

ssm = boto3.client('ssm')

def get_secret(name):
    response = ssm.get_parameter(
        Name=name,
        WithDecryption=True
    )
    return response['Parameter']['Value']

DATABASE_URL = get_secret('/myapp/prod/database_url')

Store secrets via CLI:

1
2
3
4
aws ssm put-parameter \
    --name "/myapp/prod/database_url" \
    --value "postgresql://..." \
    --type SecureString

AWS Secrets Manager (for rotation):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import boto3
import json

secrets = boto3.client('secretsmanager')

def get_secret(secret_name):
    response = secrets.get_secret_value(SecretId=secret_name)
    return json.loads(response['SecretString'])

db_creds = get_secret('myapp/prod/database')
# Returns: {"username": "admin", "password": "..."}

Kubernetes Secrets:

1
2
3
4
5
6
7
8
9
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgresql://..."
  STRIPE_KEY: "sk_live_..."
1
2
3
4
5
6
7
# deployment.yaml
spec:
  containers:
    - name: app
      envFrom:
        - secretRef:
            name: app-secrets

Level 3: HashiCorp Vault

The gold standard for serious secrets management.

Why Vault?

  • Dynamic secrets (generated on demand)
  • Automatic rotation
  • Fine-grained access control
  • Complete audit logging
  • Encryption as a service

Setup:

1
2
3
4
5
# Start dev server (not for production)
vault server -dev

# In another terminal
export VAULT_ADDR='http://127.0.0.1:8200'

Store secrets:

1
2
3
4
5
6
7
8
9
# Enable KV secrets engine
vault secrets enable -path=secret kv-v2

# Write a secret
vault kv put secret/myapp/database \
    url="postgresql://admin:secret@db.example.com/app"

# Read it back
vault kv get secret/myapp/database

Python integration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import hvac

client = hvac.Client(url='http://vault:8200')
client.token = os.environ['VAULT_TOKEN']

# Read secret
secret = client.secrets.kv.v2.read_secret_version(
    path='myapp/database'
)
DATABASE_URL = secret['data']['data']['url']

Dynamic database credentials:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Configure database secrets engine
vault secrets enable database

vault write database/config/mydb \
    plugin_name=postgresql-database-plugin \
    connection_url="postgresql://{{username}}:{{password}}@db:5432/app" \
    allowed_roles="app-role" \
    username="vault_admin" \
    password="admin_password"

vault write database/roles/app-role \
    db_name=mydb \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
    default_ttl="1h" \
    max_ttl="24h"
1
2
3
4
5
# Get dynamic credentials
creds = client.secrets.database.generate_credentials('app-role')
username = creds['data']['username']
password = creds['data']['password']
# These auto-expire after 1 hour!

Patterns That Work

1. The Twelve-Factor App Pattern

Config comes from the environment, not the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    stripe_key: str
    debug: bool = False
    
    class Config:
        env_file = ".env"

settings = Settings()

2. Config by Environment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import os

ENV = os.environ.get("ENVIRONMENT", "development")

if ENV == "production":
    from config.production import *
elif ENV == "staging":
    from config.staging import *
else:
    from config.development import *

3. Sealed Secrets for GitOps

Encrypt secrets so they can live in Git:

1
2
3
4
5
6
7
8
# Install kubeseal
brew install kubeseal

# Seal a secret
kubectl create secret generic app-secrets \
    --from-literal=api-key=secret123 \
    --dry-run=client -o yaml | \
    kubeseal --format yaml > sealed-secret.yaml
1
2
3
4
5
6
7
8
# sealed-secret.yaml (safe to commit)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: app-secrets
spec:
  encryptedData:
    api-key: AgBy8hCi...  # encrypted

4. SOPS for File Encryption

Encrypt entire config files:

1
2
3
4
5
6
7
8
# Install SOPS
brew install sops

# Encrypt with AWS KMS
sops --encrypt --kms arn:aws:kms:... secrets.yaml > secrets.enc.yaml

# Decrypt
sops --decrypt secrets.enc.yaml

CI/CD Integration

GitHub Actions:

1
2
3
4
5
6
7
8
jobs:
  deploy:
    steps:
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
        run: ./deploy.sh

With Vault:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
jobs:
  deploy:
    steps:
      - name: Import Secrets
        uses: hashicorp/vault-action@v2
        with:
          url: https://vault.example.com
          method: jwt
          role: github-actions
          secrets: |
            secret/data/myapp/database url | DATABASE_URL ;
            secret/data/myapp/stripe key | STRIPE_KEY
      
      - name: Deploy
        run: ./deploy.sh

The Rotation Problem

Secrets should rotate. Here’s how to handle it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import time
from functools import lru_cache

@lru_cache(maxsize=1)
def get_database_creds(ttl_hash=None):
    """Fetch creds with cache that expires."""
    del ttl_hash  # Only used for cache invalidation
    return vault_client.secrets.database.generate_credentials('app-role')

def get_ttl_hash(seconds=300):
    """Return same value for `seconds` duration."""
    return round(time.time() / seconds)

# Usage - creds refresh every 5 minutes
creds = get_database_creds(ttl_hash=get_ttl_hash())

Checklist

Before you ship:

  • No secrets in code or version control
  • .env files in .gitignore
  • Different secrets per environment
  • Secrets encrypted at rest
  • Access logged and auditable
  • Rotation plan in place
  • Team knows how to update secrets

The Bottom Line

Start with environment variables. Move to cloud-native secrets (Parameter Store, Secrets Manager) as you grow. Graduate to Vault when you need dynamic secrets and serious audit trails.

The best secrets management is invisible. Your app shouldn’t know or care where secrets come from — just that they’re there.


Got a secrets horror story? Share it on Twitter.