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:
. . ! e e . n n e v v n . v * . e x a m p l e
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:
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 .