Secrets Management: The Twelve-Factor Approach and Beyond

How secrets end up in git history, why environment variables aren't enough, and how to use Vault and AWS Secrets Manager properly.

A single leaked API key can compromise your entire infrastructure. Yet secrets turn up in public repositories every day — committed accidentally, embedded in Docker images, baked into configuration files. Understanding how they leak, why the basic fixes fall short, and how to use a proper secrets backend is the difference between “we think we’re fine” and actually being fine.

How Secrets End Up in Git

The most common paths are surprisingly mundane:

Direct commits: A developer adds credentials to a config file while testing and forgets to remove them before pushing.

.env file commits: The .gitignore didn’t include .env, or a new developer committed their local environment file.

Build artifacts: Secrets embedded in compiled binaries or Docker images pushed to public registries.

Git history leaks: The secret was removed in a later commit, but it’s permanently visible in the history. git log -S "SECRET_KEY" retrieves it instantly.

Log exposure: Application logs that capture full request headers or environment dumps sent to log aggregators.

Scanning for Secrets

Before changing practices, audit what’s already out there:

# truffleHog  --  scans git history including commits already "deleted"
pip install trufflehog
trufflehog git file://. --only-verified

# gitleaks  --  fast Go-based scanner, good for CI
brew install gitleaks
gitleaks detect --source . --verbose

# Check for common patterns manually
git log --all --full-history -p | grep -E "(api_key|secret|password|token)\s*=" -i

If you find a secret in history, treat it as compromised. Rotate immediately — don’t just remove it from the latest commit.

Why Environment Variables Aren’t Enough

The Twelve-Factor App methodology recommends environment variables, and that’s a significant improvement over hardcoded secrets. But env vars alone have problems:

  • They’re visible to every process on the system (/proc/PID/environ on Linux)
  • They leak into crash reports, debug outputs, and child processes
  • Rotation requires redeployment
  • No audit log of who accessed what
  • Secrets are still plaintext in your deployment configuration
# Better than hardcoding, but still has risks
import os
db_password = os.environ.get('DATABASE_PASSWORD')

# What you actually want: retrieve at runtime, not from env
import boto3
client = boto3.client('secretsmanager', region_name='us-east-1')
secret = client.get_secret_value(SecretId='prod/myapp/db')
db_password = json.loads(secret['SecretString'])['password']

HashiCorp Vault

Vault provides dynamic secrets, fine-grained access control, and a full audit trail.

Basic Secret Storage

# Store a secret
vault kv put secret/myapp/database \
  username=appuser \
  password=supersecret

# Retrieve in application
vault kv get -format=json secret/myapp/database

Application Integration (Python)

import hvac
import os

def get_db_credentials():
    client = hvac.Client(
        url=os.environ['VAULT_ADDR'],
        token=os.environ['VAULT_TOKEN']  # Or use AppRole auth
    )
    
    secret = client.secrets.kv.v2.read_secret_version(
        path='myapp/database',
        mount_point='secret'
    )
    return secret['data']['data']

# Better: use AppRole authentication instead of a static token
def vault_client_approle():
    client = hvac.Client(url=os.environ['VAULT_ADDR'])
    client.auth.approle.login(
        role_id=os.environ['VAULT_ROLE_ID'],
        secret_id=os.environ['VAULT_SECRET_ID']
    )
    return client

Dynamic Database Credentials

Vault’s database secrets engine generates time-limited credentials on demand — no shared passwords:

vault secrets enable database
vault write database/config/postgresql \
    plugin_name=postgresql-database-plugin \
    allowed_roles="my-role" \
    connection_url="postgresql://{{username}}:{{password}}@postgres:5432/mydb" \
    username="vault" \
    password="vaultpassword"

vault write database/roles/my-role \
    db_name=postgresql \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
    default_ttl="1h" \
    max_ttl="24h"

AWS Secrets Manager

import boto3
import json
from functools import lru_cache

@lru_cache(maxsize=None)
def get_secret(secret_name: str, region: str = 'us-east-1') -> dict:
    """Fetch and cache a secret. Cache lives for the process lifetime."""
    client = boto3.client('secretsmanager', region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response['SecretString'])

# Usage
creds = get_secret('prod/myapp/postgres')
db_url = f"postgresql://{creds['username']}:{creds['password']}@{creds['host']}/mydb"

Go: AWS Secrets Manager with Caching

package secrets

import (
    "context"
    "encoding/json"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

type DBCredentials struct {
    Username string `json:"username"`
    Password string `json:"password"`
    Host     string `json:"host"`
}

func GetDBCredentials(ctx context.Context, secretName string) (*DBCredentials, error) {
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return nil, err
    }
    
    client := secretsmanager.NewFromConfig(cfg)
    result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: &secretName,
    })
    if err != nil {
        return nil, err
    }
    
    var creds DBCredentials
    if err := json.Unmarshal([]byte(*result.SecretString), &creds); err != nil {
        return nil, err
    }
    return &creds, nil
}

Node.js: dotenv Best Practices

// Never import dotenv in production code  --  use it only locally
// package.json scripts: "dev": "dotenv -e .env.local -- node server.js"

// For production, use a proper secrets provider
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(secretId) {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretId })
  );
  return JSON.parse(response.SecretString);
}

Pre-commit Hooks: Stop Leaks Before They Happen

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
pip install pre-commit
pre-commit install

Where to Start

Run truffleHog or gitleaks against your git history before doing anything else — secrets may already be out there, and you need to know. Rotate any credential you find immediately; assuming a private repo is safe is a gamble that rarely pays off.

From there: pre-commit hooks to block future commits, and a real secrets backend (Vault, AWS Secrets Manager, GCP Secret Manager) for production. Environment variables are a step up from hardcoding, but they’re not a secrets management solution — rotation still requires a redeploy and there’s no audit trail.