Security Logging and Monitoring¶
You can't detect a breach if you're not logging the right events Most Node apps log nothing useful - just console.log('Server started on port 3000') and maybe an error handler if the dev was feeling fancy Meanwhile , an attacker has been probing endpoints for 6 hours and you have zero record of it
What to Log¶
const pino = require('pino')
const logger = pino()
// Security events to ALWAYS log:
// - Authentication successes and failures
// - Access denied (403s)
// - Input validation failures
// - Rate limit triggers
// - Privilege changes
// - Token operations (issue , refresh , revoke)
app.post('/api/login', async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
if (!user || !(await bcrypt.compare(password, user.password))) {
logger.warn({
event: 'LOGIN_FAILURE',
email,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString()
}, 'failed login attempt')
return res.status(401).json({ error: 'invalid credentials' })
}
logger.info({
event: 'LOGIN_SUCCESS',
userId: user.id,
ip: req.ip
}, 'successful login')
const token = generateToken(user)
res.json({ token })
})
// Access denied logging
app.use((err, req, res, next) => {
if (err.status === 403) {
logger.warn({
event: 'ACCESS_DENIED',
path: req.path,
userId: req.user?.id,
ip: req.ip,
method: req.method
}, 'access denied')
}
next(err)
})
Log the event , not the data : log that a login failed , don't log the password that was submitted That's how credentials leak
What NOT to Log¶
// DON'T - logs secrets
logger.info('DB connection:', process.env.DATABASE_URL)
// DON'T - logs tokens
logger.info('Auth header:', req.headers.authorization)
// DON'T - logs request bodies that may contain passwords
logger.info('Request body:', req.body)
// DON'T - logs PII in error messages
logger.error('User lookup failed for SSN:', ssn)
// DO - sanitize before logging
function sanitizeLog(obj) {
const sensitive = ['password', 'token', 'secret', 'authorization', 'ssn', 'creditCard']
const safe = { ...obj }
for (const key of sensitive) {
if (safe[key]) safe[key] = '[REDACTED]'
}
return safe
}
// Also handle nested objects
function deepSanitize(obj) {
if (typeof obj !== 'object' || obj === null) return obj
const sensitive = ['password', 'token', 'secret', 'authorization', 'ssn']
const result = Array.isArray(obj) ? [] : {}
for (const [key, val] of Object.entries(obj)) {
if (sensitive.includes(key.toLowerCase())) {
result[key] = '[REDACTED]'
} else if (typeof val === 'object') {
result[key] = deepSanitize(val)
} else {
result[key] = val
}
}
return result
}
Build a sanitization layer for logging : a single console.log(req.body) in an error handler can dump credit card numbers into your log aggregation service where they'll live forever
Structured Logging with Pino¶
Pino is the fastest Node.js logger - 5x faster than Winston and Bunyan
const pino = require('pino')
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label }
}
},
// Redact sensitive fields automatically
redact: {
paths: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'req.body.secret',
'req.body.ssn',
'req.body.creditCard'
],
censor: '[REDACTED]'
},
transport: {
target: 'pino/file',
options: { destination: '/var/log/app/security.log', mkdir: true }
},
serializers: {
req: pino.stdSerializers.req,
err: pino.stdSerializers.err
}
})
// Express middleware for request logging
app.use(require('pino-http')({
logger,
autoLogging: {
ignore: (req) => req.url === '/health' // skip health checks
}
}))
Pino's built-in redact prevents sensitive data from ever reaching logs : configure it in one place and never worry about logging passwords again
Log Levels¶
// FATAL - unrecoverable , app will crash
logger.fatal({ event: 'DB_CONNECTION_LOST' }, 'database unreachable')
// ERROR - request failed but server continues
logger.error({ event: 'PAYMENT_FAILURE', userId: user.id }, 'payment processing failed')
// WARN - suspicious activity , potential security event
logger.warn({ event: 'RATE_LIMIT_TRIGGERED', ip: req.ip }, 'rate limit hit')
// INFO - normal security events (logins , logouts)
logger.info({ event: 'USER_LOGOUT', userId: user.id }, 'user logged out')
// DEBUG - detailed info for investigations
logger.debug({ event: 'TOKEN_VERIFIED', userId: user.id }, 'token verified')
// TRACE - everything (use sparingly)
Log security events at WARN or above : normal operations at INFO , suspicious behavior at WARN , confirmed attacks or failures at ERROR
Correlation IDs for Request Tracing¶
const { v4: uuidv4 } = require('uuid')
// Generate correlation ID for each request
function correlationId(req, res, next) {
req.correlationId = req.headers['x-correlation-id'] || uuidv4()
res.set('X-Correlation-Id', req.correlationId)
next()
}
app.use(correlationId)
// Use in logging
const pino = require('pino')
const logger = pino()
app.use((req, res, next) => {
req.logger = logger.child({
correlationId: req.correlationId,
path: req.path,
method: req.method
})
next()
})
app.post('/api/login', async (req, res) => {
// This log includes correlationId , path , method automatically
req.logger.warn({ email }, 'failed login attempt')
// Now you can trace this request across all services
// When user reports an issue , search by correlation ID
})
Correlation IDs let you trace a single request across services : when an attacker probes 50 endpoints , you can see the entire attack chain from one correlation ID group
Log Aggregation with ELK Stack¶
// Application logs in JSON format - ELK compatible
// docker-compose.yml for local ELK
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.0
environment:
- discovery.type=single-node
logstash:
image: docker.elastic.co/logstash/logstash:8.10.0
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
depends_on:
- elasticsearch
kibana:
image: docker.elastic.co/kibana/kibana:8.10.0
ports:
- "5601:5601"
depends_on:
- elasticsearch
// Logstash configuration (logstash.conf)
input {
beats { port => 5044 }
}
filter {
json { source => "message" }
date {
match => ["timestamp", "ISO8601"]
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
index => "nodejs-security-%{+YYYY.MM.dd}"
}
}
// Filebeat ships Node logs to Logstash
// filebeat.yml
filebeat.inputs:
- type: log
paths:
- /var/log/app/*.log
json.keys_under_root: true
output.logstash:
hosts: ["logstash:5044"]
Structured JSON logging means ELK can parse your logs automatically : if you're using console.log('some text') you'll spend hours writing custom grok patterns to extract meaningful data
Alerting on Security Events¶
// Simple alert threshold monitor
class SecurityMonitor {
constructor() {
this.alertThresholds = {
LOGIN_FAILURE: { window: 300000, count: 10 }, // 10 in 5 min
ACCESS_DENIED: { window: 60000, count: 20 }, // 20 in 1 min
RATE_LIMITED: { window: 600000, count: 5 } // 5 in 10 min
}
this.events = new Map()
}
record(event, metadata) {
if (!this.events.has(event)) {
this.events.set(event, [])
}
const timestamps = this.events.get(event)
const threshold = this.alertThresholds[event]
if (!threshold) return
const now = Date.now()
timestamps.push(now)
// Remove old entries
const cutoff = now - threshold.window
const recent = timestamps.filter(t => t > cutoff)
this.events.set(event, recent)
if (recent.length >= threshold.count) {
this.triggerAlert(event, recent.length, threshold)
}
}
triggerAlert(event, count, threshold) {
logger.fatal({
event: 'SECURITY_ALERT',
alertType: event,
triggerCount: count,
threshold: threshold.count,
window: threshold.window
}, 'security threshold exceeded')
// Send to PagerDuty , Slack , email - whatever
}
}
const monitor = new SecurityMonitor()
// Usage in endpoints
app.post('/api/login', async (req, res) => {
// ... login logic
if (!valid) {
monitor.record('LOGIN_FAILURE', { email, ip: req.ip })
}
})
Automated alerting catches brute force in real-time : you can't watch logs 24/7 but your security monitor can
Prerequisites¶
- sec_08_env_config.md - secure config before logging
next -> deploy_01_env_setup.md