Secure Configuration¶
The most common data breach in Node apps? Hardcoded secrets in source code API keys in GitHub , database passwords in config files , JWT secrets set to "changeme" - I've seen it all in production repos that were supposed to be private Let's fix this shit permanently
Environment Variables with dotenv¶
# .env - never commit this file
PORT=3000
NODE_ENV=production
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
JWT_SECRET=definitely-not-a-real-secret-change-me
REDIS_URL=redis://:password@localhost:6379
STRIPE_API_KEY=sk_live_abc123def456
SENDGRID_API_KEY=SG.xxxxx
SESSION_SECRET=generate-a-real-random-64-char-string
# .env.example - commit this
PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
JWT_SECRET=change-this-in-production
REDIS_URL=redis://localhost:6379
STRIPE_API_KEY=sk_test_xxx
SESSION_SECRET=change-this-in-production
// .gitignore
.env
.env.local
.env.production
.env.*.local
// load in app
require('dotenv').config()
// or with custom path
require('dotenv').config({
path: `.env.${process.env.NODE_ENV || 'development'}`
})
// access
const dbUrl = process.env.DATABASE_URL
Never commit .env to git : once a secret hits the git history , it's compromised even if you delete it later Use .env.example with placeholder values so new devs know what they need
Secrets Management: Beyond dotenv¶
For production , environment variables are just the start - you need a real secrets manager
// HashiCorp Vault
const vault = require('node-vault')({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR || 'http://127.0.0.1:8200',
token: process.env.VAULT_TOKEN
})
async function getSecret(path) {
const { data } = await vault.read(`secret/data/${path}`)
return data.data
}
// Usage
const dbCreds = await getSecret('database/production')
const pool = new Pool({
connectionString: dbCreds.url
})
// AWS Secrets Manager
const { SecretsManager } = require('@aws-sdk/client-secrets-manager')
const client = new SecretsManager({ region: 'us-east-1' })
async function getDbSecret() {
const data = await client.getSecretValue({
SecretId: 'prod/database/url'
})
return JSON.parse(data.SecretString)
}
// Doppler
const doppler = require('@dopplerhq/node-sdk')
const secrets = doppler.secrets({ project: 'myapp', config: 'prd' })
Use secrets manager in production : env vars are better than hardcoded but still visible in process listings , container logs , and debugging tools
Config Validation with convict¶
const convict = require('convict')
const schema = {
env: {
doc: 'Application environment',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV'
},
port: {
doc: 'HTTP port',
format: 'port',
default: 3000,
env: 'PORT',
arg: 'port'
},
db: {
url: {
doc: 'Database connection string',
format: String,
default: null,
env: 'DATABASE_URL',
sensitive: true // don't print in toString()
},
pool: {
min: {
doc: 'Minimum pool connections',
format: 'nat',
default: 2,
env: 'DB_POOL_MIN'
},
max: {
doc: 'Maximum pool connections',
format: 'nat',
default: 10,
env: 'DB_POOL_MAX'
}
}
},
jwt: {
secret: {
doc: 'JWT signing secret',
format: function(val) {
if (!val || val.length < 32) {
throw new Error('JWT secret must be 32+ chars')
}
},
default: null,
env: 'JWT_SECRET',
sensitive: true
},
expiresIn: {
doc: 'JWT expiration',
format: String,
default: '15m',
env: 'JWT_EXPIRES_IN'
}
},
redis: {
url: {
doc: 'Redis connection string',
format: String,
default: null,
env: 'REDIS_URL',
sensitive: true
}
}
}
const config = convict(schema)
config.validate({ allowed: 'strict' }) // throws if missing required
// config.toString() -> hides sensitive fields
module.exports = config
const config = require('./config')
const app = express()
app.listen(config.get('port'))
Validate config at startup : catching a missing JWT_SECRET when the server starts is infinitely better than catching it when it fails to sign tokens at 3AM
Config Validation with envalid¶
const { cleanEnv, str, port, url, num } = require('envalid')
const env = cleanEnv(process.env, {
NODE_ENV: str({ choices: ['development', 'test', 'production'] }),
PORT: port({ default: 3000 }),
DATABASE_URL: url(),
JWT_SECRET: str({ desc: 'JWT signing key , min 32 chars' }),
REDIS_URL: url(),
STRIPE_API_KEY: str(),
LOG_LEVEL: str({ choices: ['debug', 'info', 'warn', 'error'], default: 'info' }),
MAX_REQUESTS_PER_MINUTE: num({ default: 100 })
})
// env.DATABASE_URL is a validated URL
// env.PORT is a valid port number
// env.STRIPE_API_KEY is a non-empty string
const pool = new Pool({ connectionString: env.DATABASE_URL })
cleanEnv throws on invalid config : it also freezes the env object so nothing can tamper with it later
Security Rules for Configuration¶
// Rule 1: Validate at startup
function validateConfig() {
const required = [
'DATABASE_URL', 'JWT_SECRET', 'REDIS_URL'
]
const missing = required.filter(key => !process.env[key])
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(', ')}`)
process.exit(1)
}
}
validateConfig()
// Rule 2: Never log secrets
function safeConfig() {
const safe = { ...process.env }
const sensitive = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY', 'PASSWORD']
for (const key of sensitive) {
if (safe[key]) safe[key] = '***REDACTED***'
}
return safe
}
console.log('Config:', safeConfig())
// Rule 3: Use defaults for dev , fail for prod
const dbUrl = process.env.DATABASE_URL || (
process.env.NODE_ENV === 'production'
? (() => { throw new Error('DATABASE_URL required in prod') })()
: 'postgresql://localhost:5432/dev'
)
Fail fast on missing config in production : a missing secret should crash the process immediately , not silently default to something insecure
Prerequisites¶
- sec_07_dependency_audit.md - secure your supply chain
next -> sec_09_logging.md