In March 2025, a developer at a mid-sized SaaS company committed an AWS IAM key to a public GitHub repository. Within 14 minutes — not hours, minutes — an automated bot detected the key, spun up 47 EC2 instances for cryptocurrency mining, and generated a $28,000 AWS bill before the key was revoked. This is not an unusual story. GitHub's secret scanning service detects over 100 million exposed secrets per year across public repositories. GitGuardian's 2025 State of Secrets Sprawl report found that the average organization has 5.2 hardcoded secrets per developer, with 72% of those secrets still active (not rotated) at the time of detection.
Secrets management is the practice of storing, accessing, and rotating credentials (API keys, database passwords, TLS certificates, SSH keys, OAuth tokens) through dedicated systems rather than hardcoding them in source code, environment variables, or configuration files. This guide covers the three most practical solutions for different scales of operation: SOPS for small teams, Vault for enterprises, and External Secrets Operator for Kubernetes-native workflows.
The Problem with .env Files
Environment variables loaded from .env files are the most common approach to secrets management — and the most dangerous at scale. The problems are fundamental, not cosmetic:
- No encryption at rest: .env files are plaintext. Anyone with file system access (or a backup) has every secret.
- No access control: If you can read the file, you have every secret in it. There is no concept of "this developer can access the database password but not the AWS root key."
- No audit trail: You cannot determine who accessed which secret, when, or how often.
- No rotation mechanism: Changing a secret means editing the file on every server, restarting every service, and hoping you did not miss one.
- Git history contamination: Even if you .gitignore the .env file now, it may have been committed in the past.
git log --all --full-history -- .envwill reveal the truth.
The .env approach works for local development of personal projects. For anything that handles user data, processes payments, or runs in production, you need a real secrets management system.
Solution 1: Mozilla SOPS for Small Teams
SOPS (Secrets OPerationS) by Mozilla encrypts configuration files using age, AWS KMS, GCP KMS, or Azure Key Vault. The encrypted files can be safely committed to Git because only the values are encrypted — the keys (field names) remain visible for easy review and diffing. This is the best solution for teams of 1-20 developers who want encrypted secrets without running additional infrastructure:
# Install SOPS and age:
brew install sops age # macOS
sudo apt install sops age # Ubuntu (or download from GitHub releases)
# Generate an age key pair:
age-keygen -o ~/.config/sops/age/keys.txt
# The public key is printed — share this with your team.
# The private key stays in this file — never share it.
# Create a SOPS configuration file:
cat > .sops.yaml <<'SOPS'
creation_rules:
# Production secrets: encrypted with age + AWS KMS
- path_regex: secrets/production/.*
age: "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
kms: "arn:aws:kms:us-east-1:123456789:key/abcd-1234"
# Development secrets: encrypted with age only
- path_regex: secrets/development/.*
age: "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
SOPS
# Create an encrypted secrets file:
sops secrets/production/api.yaml
# This opens your editor. Type your secrets in YAML:
# database_url: postgres://user:pass@host:5432/db
# stripe_secret_key: sk_live_xxxxx
# jwt_secret: super-secret-key-here
# When you save and close, SOPS encrypts the values:
cat secrets/production/api.yaml
# database_url: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]
# stripe_secret_key: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]
# jwt_secret: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]
# Decrypt and use in your application:
sops --decrypt secrets/production/api.yaml
# Use in a deployment script:
export DATABASE_URL=$(sops --decrypt --extract '["database_url"]' secrets/production/api.yaml)
# Edit an existing encrypted file:
sops secrets/production/api.yaml
# SOPS decrypts it, opens your editor, and re-encrypts when you save.
SOPS files can be safely committed to Git. The encryption keys never touch the repository — they are stored in KMS or local age key files. When a team member leaves, you rotate the age key and re-encrypt all files. When a secret needs to change, you edit the SOPS file, commit the change, and the deployment pipeline decrypts the updated value automatically.
Solution 2: HashiCorp Vault for Enterprise Teams
Vault is the industry standard for centralized secrets management. It provides dynamic secret generation (creates database credentials on the fly and revokes them after use), fine-grained access control policies, detailed audit logging, and automatic secret rotation. The trade-off is operational complexity — Vault itself needs to be deployed, secured, and maintained:
# Install Vault (production deployment uses Raft storage):
# docker-compose.yml for a single-node Vault:
version: '3.8'
services:
vault:
image: hashicorp/vault:1.17
cap_add:
- IPC_LOCK
ports:
- "8200:8200"
environment:
VAULT_ADDR: "http://0.0.0.0:8200"
volumes:
- vault-data:/vault/data
- ./vault-config.hcl:/vault/config/config.hcl
command: vault server -config=/vault/config/config.hcl
volumes:
vault-data:
# vault-config.hcl:
storage "raft" {
path = "/vault/data"
node_id = "vault-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 0
tls_cert_file = "/vault/tls/cert.pem"
tls_key_file = "/vault/tls/key.pem"
}
api_addr = "https://vault.yourdomain.com:8200"
cluster_addr = "https://vault.yourdomain.com:8201"
ui = true
disable_mlock = false
# Initialize and unseal Vault:
export VAULT_ADDR='https://vault.yourdomain.com:8200'
vault operator init -key-shares=5 -key-threshold=3
# Save the 5 unseal keys and root token SECURELY.
# Distribute unseal keys to different team members.
vault operator unseal # Run 3 times with different keys
# Enable the KV secrets engine:
vault secrets enable -path=secret kv-v2
# Store a secret:
vault kv put secret/production/database \
url="postgres://app:s3cureP@ss@db.internal:5432/myapp" \
readonly_url="postgres://reader:r3adOnly@db-replica.internal:5432/myapp"
# Read a secret:
vault kv get -field=url secret/production/database
# Create a policy for the application:
vault policy write app-production - <<'POLICY'
# Application can read production secrets:
path "secret/data/production/*" {
capabilities = ["read"]
}
# Application CANNOT list, create, update, or delete:
path "secret/metadata/*" {
capabilities = ["deny"]
}
POLICY
# Create an AppRole for the application:
vault auth enable approle
vault write auth/approle/role/myapp \
secret_id_ttl=720h \
token_ttl=1h \
token_max_ttl=4h \
policies="app-production"
# Get Role ID and Secret ID for the application:
vault read auth/approle/role/myapp/role-id
vault write -f auth/approle/role/myapp/secret-id
In your Node.js application, use the official Vault client library:
// vault-client.ts
import Vault from 'node-vault';
const vault = Vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
});
async function initializeVault() {
// Authenticate with AppRole:
const loginResult = await vault.approleLogin({
role_id: process.env.VAULT_ROLE_ID,
secret_id: process.env.VAULT_SECRET_ID,
});
vault.token = loginResult.auth.client_token;
}
async function getSecret(path: string, key: string): Promise<string> {
const result = await vault.read(`secret/data/${path}`);
return result.data.data[key];
}
// Usage:
await initializeVault();
const dbUrl = await getSecret('production/database', 'url');
// The database URL is never in an env variable, config file, or source code.
Solution 3: External Secrets Operator for Kubernetes
If your workloads run on Kubernetes, External Secrets Operator (ESO) synchronizes secrets from external providers (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) into Kubernetes Secrets automatically. Your pods consume standard Kubernetes Secrets, but the actual secret values are managed externally:
# Install External Secrets Operator:
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
# Create a SecretStore that connects to Vault:
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: vault-backend
namespace: production
spec:
provider:
vault:
server: "https://vault.internal:8200"
path: "secret"
version: "v2"
auth:
appRole:
path: "approle"
roleRef:
name: vault-approle
key: role-id
secretRef:
name: vault-approle
key: secret-id
# Create an ExternalSecret that syncs specific secrets:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
namespace: production
spec:
refreshInterval: 5m # Check for updates every 5 minutes
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: app-secrets # The Kubernetes Secret that will be created
creationPolicy: Owner
data:
- secretKey: DATABASE_URL
remoteRef:
key: production/database
property: url
- secretKey: STRIPE_KEY
remoteRef:
key: production/payments
property: stripe_secret_key
Automated Secret Rotation
Secrets that never rotate are secrets waiting to be compromised. Implement automated rotation for every secret type:
#!/bin/bash
# rotate-db-password.sh — Run weekly via cron or CI/CD
set -euo pipefail
NEW_PASSWORD=$(openssl rand -base64 32)
DB_HOST="db.internal"
DB_USER="app_user"
DB_NAME="myapp"
# Step 1: Set the new password in the database:
PGPASSWORD=$OLD_PASSWORD psql -h $DB_HOST -U postgres -c \
"ALTER USER $DB_USER PASSWORD '$NEW_PASSWORD';"
# Step 2: Update the secret in Vault:
vault kv put secret/production/database \
url="postgres://$DB_USER:$NEW_PASSWORD@$DB_HOST:5432/$DB_NAME"
# Step 3: Restart the application pods to pick up the new secret:
# (If using ESO with refreshInterval, this happens automatically)
kubectl rollout restart deployment/myapp -n production
# Step 4: Log the rotation event:
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Rotated database password for $DB_USER" >> /var/log/secret-rotation.log
The goal of secrets management is simple: no human should ever see a production secret in plaintext, no secret should live longer than its rotation period, and every access should be logged. SOPS gets you encrypted files in Git. Vault gives you centralized management with audit trails. External Secrets Operator bridges Vault to Kubernetes natively. Choose the solution that matches your infrastructure complexity — but choose something beyond .env files. ZeonEdge implements secrets management pipelines as part of our infrastructure security service. Learn about our security architecture services.
Sarah Chen
Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.