The .env File Is Not a Security Strategy
Your .env file is the first place attackers look. Here's how to actually protect secrets.
The Problem with .env Files
# .env — this is NOT secure!
DATABASE_URL=postgresql://admin:SuperSecret123@db:5432/mydb
STRIPE_SECRET=sk_live_51Habc123...
AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
JWT_SECRET=my-super-secret-jwt-key-12345
# Problems:
# 1. Committed to git (even with .gitignore, accidents happen)
# 2. Shared in screenshots/chat logs
# 3. Deployed in Docker images (docker history shows it!)
# 4. Logged in crash reports
# 5. Visible to anyone with server access
The Real Security Strategy
Layer 1: Never Commit .env Files
# .gitignore — non-negotiable
.env
.env.local
.env.*.local
!.env.example
# Pre-commit hook to catch accidental commits
#!/bin/bash
# .git-hooks/pre-commit
if git diff --cached --name-only | grep -q '\.env'; then
echo "❌ ERROR: .env file detected in staged changes!"
exit 1
fi
Layer 2: Use Environment-Specific Configs
// config/index.js — centralized config management
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'development'}` });
module.exports = {
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE || '10'),
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
// Validate required vars at startup
validate() {
const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}
}
};
Layer 3: Production Secrets Management
Option A: Cloud Secret Managers (Best for production)
// Using AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
async function getSecret(secretName) {
const client = new SecretsManagerClient();
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
return JSON.parse(response.SecretString);
}
// Usage
const secrets = await getSecret('myapp/production');
// { database_url: "...", jwt_secret: "..." }
Option B: HashiCorp Vault (Self-hosted)
# Store secrets in Vault
vault kv put secret/myapp \
database_url="postgresql://..." \
jwt_secret="..." \
stripe_key="sk_live..."
# Retrieve in app
export VAULT_ADDR='https://vault.example.com'
vault kv get -field=database_url secret/myapp
Option C: Encrypted .env (Simplest for small teams)
# Install sops (Mozilla's encrypted env tool)
brew install sops # or apt install sops
# Create encrypted .env file
sops --encrypt --kms "arn:aws:kms:region:key-id" .env.enc > .env.enc
# In CI/CD:
sops --decrypt --output .env .env.enc
source .env && npm start
Layer 4: Runtime Protection
// Prevent secrets from leaking into logs
const REDACT_KEYS = ['password', 'secret', 'key', 'token', 'credential'];
function redact(obj) {
if (!obj || typeof obj !== 'object') return obj;
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const lowerKey = key.toLowerCase();
if (REDACT_KEYS.some(r => lowerKey.includes(r))) {
return [key, '[REDACTED]'];
}
return [key, redact(value)];
})
);
}
// Use in logging
logger.info('Request data:', redact(req.body));
logger.info('Config loaded:', redact(config));
Layer 5: Rotation & Expiry
// JWT secret rotation support
function verifyToken(token) {
// Try current secret first
try { return jwt.verify(token, currentSecret); } catch {}
// Try previous secret (graceful rotation)
try { return jwt.verify(token, previousSecret); } catch {}
throw new AuthError('Invalid token');
}
The .env Template Pattern
# .env.example — commit THIS to git (not .env!)
# Copy this file to .env and fill in your values
# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DB_POOL_SIZE=10
# Redis
REDIS_URL=redis://localhost:6379
# Authentication
JWT_SECRET=generate-with:openssl rand -base64 32
JWT_EXPIRES_IN=7d
# External Services
STRIPE_PUBLIC=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
SENDGRID_API_KEY=sg_xxx
# Feature Flags
ENABLE_SIGNUP=true
RATE_LIMIT=100
# Onboarding script for new developers
#!/bin/bash
if [ ! -f .env ]; then
cp .env.example .env
echo "✅ Created .env from template"
echo "⚠️ Edit .env with your values before starting!"
else
echo ".env already exists"
fi
Docker & .env
# ❌ BAD: Build args expose secrets in image layers
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL # Visible in `docker history`!
# ✅ GOOD: Only inject at runtime via docker-compose
# docker-compose.yml
services:
app:
build: .
env_file:
- .env.production # Not committed to git!
environment:
- NODE_ENV=production
# Or use Docker secrets (swarm mode)
services:
app:
secrets:
- db_password
- jwt_secret
secrets:
db_password:
external: true
jwt_secret:
external: true
The Security Checklist
- [ ]
.envin.gitignore(always) - [ ]
.env.examplecommitted (template only, no real values) - [ ] Pre-commit hook prevents .env commits
- [ ] Different credentials per environment (dev/staging/prod)
- [ ] No secrets in source code or config files
- [ ] No secrets in Docker images (use runtime injection)
- [ ] Secrets rotated regularly (especially after breaches)
- [ ] Access logging for sensitive operations
- [ ] Secrets not in error messages or logs
- [ ] CI/CD uses encrypted secrets (GitHub Actions secrets, etc.)
- [ ] Team members know the security policy
Quick Reference: Where to Store Secrets
| Environment | Tool | Best For |
|---|---|---|
| Local dev |
.env + dotenv |
Convenience |
| Staging | CI/CD secrets | Automated deploys |
| Production | AWS SM / GCP SM / Azure KV | Enterprise |
| Self-hosted | HashiCorp Vault | Full control |
| Small team | SOPS encrypted files | Git-based workflow |
How do you manage secrets in your projects? Any horror stories?
Follow @armorbreak for more security tips.
Top comments (1)
use varlock.dev - its a nice toolkit (open source and free!) to help deal with all of this.