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, or baked into configuration files. This guide covers how secrets leak, why the basic fixes fall short, and how to implement secrets management that scales.
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
Key Takeaways
- Scan your git history now with truffleHog or gitleaks—secrets may already be exposed.
- Rotate any credential you find immediately, even if you think the repo is private.
- Environment variables are a step up from hardcoding but are not a secrets management solution.
- For production workloads, use Vault or a cloud-native service like AWS Secrets Manager or GCP Secret Manager.
- Add pre-commit hooks to block secrets from being committed in the first place.