Secrets Management: Environment Variables and Vault
In 2019, Capital One suffered a breach affecting 100 million customers. The root cause? Misconfigured AWS credentials that allowed an attacker to access S3 buckets containing sensitive data. Uber...
Key Insights
- Environment variables are a reasonable starting point for secrets management, but they offer no encryption at rest, audit logging, or rotation capabilities—know their limits before relying on them in production.
- HashiCorp Vault transforms secrets from static liabilities into dynamic, auditable, automatically-rotated credentials that reduce your blast radius when breaches occur.
- Your secrets management strategy should match your threat model: a three-person startup doesn’t need Vault, but any organization handling sensitive data at scale probably does.
The Cost of Leaked Secrets
In 2019, Capital One suffered a breach affecting 100 million customers. The root cause? Misconfigured AWS credentials that allowed an attacker to access S3 buckets containing sensitive data. Uber paid $100,000 to hackers in 2016 after engineers committed AWS credentials to a GitHub repository. These aren’t edge cases—GitGuardian detected over 10 million secrets exposed in public GitHub commits in 2022 alone.
A “secret” in application architecture is any credential that grants access to protected resources: API keys, database passwords, encryption keys, OAuth tokens, TLS certificates, and service account credentials. The fundamental challenge is that your application needs these secrets to function, but every copy of a secret is a potential leak vector.
Most teams start with the simplest approach: environment variables. Let’s examine when that’s sufficient and when you need something more robust.
Environment Variables: The Basics
The twelve-factor app methodology popularized environment variables as the standard mechanism for configuration injection. The reasoning is sound: env vars are language-agnostic, they separate config from code, and they’re supported everywhere.
Here’s how you typically load them in Node.js:
// Direct access
const dbPassword = process.env.DATABASE_PASSWORD;
// With dotenv for local development
require('dotenv').config();
const config = {
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
password: process.env.DB_PASSWORD,
},
apiKey: process.env.EXTERNAL_API_KEY,
};
Python follows a similar pattern:
import os
from dotenv import load_dotenv
load_dotenv() # Load from .env file in development
DATABASE_URL = os.environ.get("DATABASE_URL")
SECRET_KEY = os.environ["SECRET_KEY"] # Raises KeyError if missing
Docker makes env var injection straightforward:
# docker-compose.yml
services:
api:
image: myapp:latest
environment:
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
- API_KEY=${API_KEY}
env_file:
- .env.production
Environment variables work well when you have a small team, limited secrets, and straightforward deployment pipelines. They’re easy to understand and require no additional infrastructure.
Environment Variable Security Pitfalls
The simplicity of env vars comes with serious security trade-offs that many teams discover too late.
Accidental logging is the most common leak vector:
// This happens more often than you'd think
app.use((req, res, next) => {
console.log('Request context:', {
headers: req.headers,
env: process.env, // Logs ALL secrets
});
next();
});
// Or during error handling
process.on('uncaughtException', (err) => {
logger.error('Fatal error', {
error: err,
config: process.env // Secrets in your error tracking service
});
});
The fix requires discipline and explicit filtering:
const sanitizeEnv = (env) => {
const sensitiveKeys = ['PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'CREDENTIAL'];
return Object.fromEntries(
Object.entries(env).map(([key, value]) => {
const isSensitive = sensitiveKeys.some(s => key.toUpperCase().includes(s));
return [key, isSensitive ? '[REDACTED]' : value];
})
);
};
logger.info('Application config', { env: sanitizeEnv(process.env) });
Child process inheritance means any subprocess can read your secrets:
import subprocess
import os
# This child process inherits ALL environment variables
subprocess.run(["some-third-party-tool", "--process-data"])
# If that tool is compromised, it has your secrets
Container inspection exposes env vars to anyone with Docker access:
# Anyone with docker access can run this
docker inspect mycontainer --format='{{.Config.Env}}'
# Output: [DATABASE_PASSWORD=supersecret API_KEY=sk-12345...]
CI/CD logs frequently leak secrets through build output, especially when debugging failing deployments.
Introduction to HashiCorp Vault
Vault addresses the fundamental limitations of environment variables by treating secrets as dynamic, auditable, access-controlled resources rather than static strings.
Core capabilities that matter:
- Dynamic secrets: Generate credentials on-demand with automatic expiration
- Audit logging: Every secret access is logged with identity and timestamp
- Fine-grained access control: Policies define exactly who can access what
- Encryption as a service: Encrypt data without exposing keys to applications
- Automatic rotation: Credentials rotate without application changes
Here’s how to get started with Vault:
# Start a dev server (never use dev mode in production)
vault server -dev
# Enable the KV secrets engine
vault secrets enable -path=secret kv-v2
# Store a secret
vault kv put secret/myapp/database password="db-password-here"
# Retrieve it
vault kv get secret/myapp/database
Vault’s policy system controls access:
# policy.hcl - Allow read-only access to myapp secrets
path "secret/data/myapp/*" {
capabilities = ["read"]
}
path "secret/metadata/myapp/*" {
capabilities = ["list"]
}
Integrating Vault with Applications
There are three primary patterns for getting secrets from Vault into your application.
Direct API integration gives you the most control:
import hvac
import os
client = hvac.Client(url='https://vault.example.com:8200')
# AppRole authentication (common for services)
client.auth.approle.login(
role_id=os.environ['VAULT_ROLE_ID'],
secret_id=os.environ['VAULT_SECRET_ID']
)
# Fetch secrets
secret = client.secrets.kv.v2.read_secret_version(
path='myapp/database',
mount_point='secret'
)
db_password = secret['data']['data']['password']
Kubernetes sidecar injection handles authentication automatically:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "myapp"
vault.hashicorp.com/agent-inject-secret-db: "secret/data/myapp/database"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "secret/data/myapp/database" -}}
export DATABASE_PASSWORD="{{ .Data.data.password }}"
{{- end }}
spec:
serviceAccountName: myapp
containers:
- name: myapp
image: myapp:latest
Vault Agent runs as a daemon and maintains a local cache:
# vault-agent-config.hcl
auto_auth {
method "kubernetes" {
mount_path = "auth/kubernetes"
config = {
role = "myapp"
}
}
sink "file" {
config = {
path = "/tmp/vault-token"
}
}
}
template {
source = "/etc/vault/templates/db.tpl"
destination = "/etc/secrets/db.env"
}
Secrets Rotation and Dynamic Credentials
Static credentials are a liability—if leaked, they remain valid until manually rotated. Vault’s dynamic secrets solve this by generating short-lived credentials on demand.
Configure the database secrets engine for PostgreSQL:
# Enable the database secrets engine
vault secrets enable database
# Configure PostgreSQL connection
vault write database/config/myapp-postgres \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/myapp" \
allowed_roles="myapp-role" \
username="vault-admin" \
password="admin-password"
# Create a role that generates credentials
vault write database/roles/myapp-role \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
Now your application requests credentials dynamically:
def get_database_connection():
# Request dynamic credentials
creds = vault_client.secrets.database.generate_credentials(
name='myapp-role',
mount_point='database'
)
username = creds['data']['username']
password = creds['data']['password']
lease_duration = creds['lease_duration']
# Credentials auto-expire after lease_duration
return create_connection(username, password)
If these credentials leak, they’re valid for at most one hour. Your blast radius shrinks dramatically.
Choosing Your Strategy: Decision Framework
Use environment variables alone when:
- You have fewer than 10 secrets
- Your team is small (under 5 engineers)
- You’re not handling sensitive customer data
- You have no compliance requirements (SOC2, HIPAA, PCI)
- Your deployment pipeline is simple and trusted
Adopt Vault when:
- You need audit logging for compliance
- Multiple services share secrets
- You want automatic credential rotation
- You’re running in Kubernetes at scale
- You need dynamic database credentials
- A breach would have significant business impact
Hybrid approach works for many teams: use Vault for production secrets while keeping env vars for local development and non-sensitive configuration.
The migration path is straightforward: start by moving your most sensitive secrets (database credentials, API keys for payment processors) to Vault while leaving less critical configuration in env vars. Expand coverage as your team builds familiarity.
Don’t over-engineer early. A startup with three engineers and one production database doesn’t need Vault—they need a password manager and careful .gitignore rules. But the moment you’re handling customer financial data or health records, the calculus changes. The cost of operating Vault is trivial compared to the cost of a breach.
Secrets management isn’t a solved problem you implement once. It’s an ongoing practice of reducing the number of secrets, limiting their scope, shortening their lifespan, and logging their access. Start where you are, but know where you’re headed.