The .env File Is Not a Security Strategy
Every week, another company leaks secrets through environment variables. Here's how to stop being one of them.
The Problem
# Your .env file (NEVER do this)
DATABASE_URL=postgres://admin:password123@db.example.com/myapp
STRIPE_SECRET_KEY=sk_live_abc123...
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
JWT_SECRET=my-super-secret-jwt-key-12345
SENDGRID_API_KEY=SG.abc123...
One git add . with a missing .gitignore entry. One docker commit that includes the env layer. One debug log that dumps process.env.
Your secrets are now on GitHub. Forever.
The Hard Truth About .env Files
.env files give you a false sense of security:
| Myth | Reality |
|---|---|
| ".env is excluded from git" | Until someone forgets .gitignore |
| "Environment variables are secure" | They're visible in process managers, crash dumps, child processes |
| "Docker secrets are safe" | They're in image layers if you use ENV |
| "CI/CD masks secrets" | Only in logs. They're still in memory during the run |
What to Do Instead
Layer 1: Secret Manager
// secrets.js — Load from environment OR secret manager
const AWS = require('@aws-sdk/client-secrets-manager');
class Secrets {
constructor() {
this.cache = new Map();
this.ttl = 5 * 60 * 1000; // 5 min cache
}
async get(key) {
// Check cache first
const cached = this.cache.get(key);
if (cached && Date.now() - cached.time < this.ttl) {
return cached.value;
}
// Try environment first (for local dev)
if (process.env[key]) {
return process.env[key];
}
// Fall back to secret manager
try {
const client = new AWS.SecretsManager({});
const response = await client.getSecretValue({
SecretId: `myapp/${key}`,
});
const value = response.SecretString;
this.cache.set(key, { value, time: Date.now() });
return value;
} catch (err) {
throw new Error(`Secret "${key}" not found in env or secret manager`);
}
}
}
module.exports = new Secrets();
Layer 2: Git Pre-Commit Hook
#!/bin/bash
# .git/hooks/pre-commit — Block secrets from being committed
# Check staged files for common secret patterns
PATTERNS=(
'password\s*[:=]\s*["\x27][^\s"\x27]{8,}'
'api[_-]?key\s*[:=]\s*["\x27][^\s"\x27]{10,}'
'secret\s*[:=]\s*["\x27][^\s"\x27]{8,}'
'AKIA[0-9A-Z]{16}'
'sk_live_[a-zA-Z0-9]{20,}'
'-----BEGIN (RSA |DSA |EC )?PRIVATE KEY-----'
)
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FOUND_SECRETS=false
for file in $STAGED; do
if [ ! -f "$file" ]; then continue; fi
for pattern in "${PATTERNS[@]}"; do
if grep -qiE "$pattern" "$file"; then
echo "🔒 WARNING: Possible secret detected in $file"
echo " Pattern: $pattern"
FOUND_SECRETS=true
fi
done
done
if [ "$FOUND_SECRETS" = true ]; then
echo ""
echo "❌ Commit blocked: Possible secrets detected."
echo " Use --no-verify to bypass (NOT recommended)."
exit 1
fi
Layer 3: Environment-Specific Configs
// config.js — Type-safe, environment-aware configuration
const z = require('zod');
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().optional(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
function loadConfig() {
// Validate environment variables at startup
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment configuration:');
result.error.errors.forEach(err => {
console.error(` ${err.path.join('.')}: ${err.message}`);
});
process.exit(1); // Fail fast
}
return {
...result.data,
isDev: result.data.NODE_ENV === 'development',
isProd: result.data.NODE_ENV === 'production',
};
}
const config = loadConfig();
module.exports = config;
Docker: The Right Way
# ❌ WRONG: Secrets in ENV
ENV DATABASE_URL=postgres://admin:password@db/myapp
# ❌ WRONG: Secrets in ARG (visible in image history)
ARG DATABASE_URL
# ✅ CORRECT: Runtime-only environment variables
# Pass at docker run:
# docker run -e DATABASE_URL=$DATABASE_URL myapp
# ✅ BEST: Docker secrets (Swarm/Kubernetes)
# secrets are mounted as files, never in env vars
# docker-compose.yml
version: '3.8'
services:
app:
image: myapp:latest
secrets:
- db_password
environment:
- DATABASE_HOST=db
- DATABASE_USER=admin
# Password comes from file, not env
- DATABASE_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt # NOT committed to git
// Read secret from file
const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();
CI/CD: Preventing Leaks
GitHub Actions
# Use GitHub Secrets, never hardcode
jobs:
deploy:
steps:
- uses: actions/checkout@v4
# ✅ Good: Use secrets context
- run: npm run deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DB_URL: ${{ secrets.DATABASE_URL }}
# ❌ Bad: Never use env context (visible in logs)
# - run: echo $API_KEY
Scan for Existing Leaks
# Install git-secrets (Amazon's tool)
git clone https://github.com/awslabs/git-secrets.git
cd git-secrets && make install
# Scan entire repo history
cd your-repo
git secrets --scan-history
# Register patterns to watch for
git secrets --register-aws
git secrets --add 'sk_live_[a-zA-Z0-9]{20,}'
# Or use truffleHog (open source secret scanner)
pip install trufflehog
trufflehog --regex --entropy=True git://github.com/yourorg/yourrepo.git
If You Already Leaked a Secret
# 1. Rotate the secret IMMEDIATELY
# Don't just remove it from git — it's already in history
# 2. Remove from git history
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch .env' \
--prune-empty --tag-name-filter cat -- --all
# 3. Force push (coordinate with team first!)
git push origin --force --all
# 4. Add to .gitignore
echo ".env" >> .gitignore
echo ".env.*" >> .gitignore
echo "!/.env.example" >> .gitignore
# 5. Scan for the leaked secret elsewhere
# Check: GitHub search, Pastebin, other repos
The Checklist
- [ ]
.envin.gitignore(and.env.*except.env.example) - [ ]
.env.examplecommitted with placeholder values - [ ] No secrets in Dockerfile (ENV/ARG)
- [ ] Pre-commit hook scans for secrets
- [ ] CI/CD uses secret managers, not env vars
- [ ] Zod validation at app startup
- [ ]
git filter-branchhistory cleaned if ever leaked - [ ] Secret rotation schedule (every 90 days)
- [ ] Audit log for secret access (production)
A .env file is a development convenience, not a security boundary. Treat it accordingly.
Follow @armorbreak for more production-ready security practices.
Top comments (0)