Skip to content

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

next -> deploy_01_env_setup.md