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/environon 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.