Most breaches are discovered by someone outside the company, months after they began. OWASP's "Security Logging and Monitoring Failures" (A09) is on the list because the attacks in the earlier lessons all leave traces, and those traces are worthless if nobody records them, nobody can search them, or nobody is alerted when they appear.
In this lesson you will learn which events a security log must contain, how to structure logs so they are searchable and safe, how to keep secrets and injected text out of them, and how to turn logs into alerts and an audit trail you can rely on during an incident.
The goal is to answer "who did what, to which resource, from where, and did it succeed" for every security-relevant action:
| Event category | Examples | Must include | |---|---|---| | Authentication | Login success and failure, logout, MFA enrolment, password reset | User identifier, IP, outcome | | Authorization | 403 responses, attempts to access another tenant's data | User, resource ID, route | | Input validation | Schema rejections, blocked file uploads, CSP reports | Field or rule, request ID | | Administrative | Role changes, config changes, data exports, user deletions | Actor, target, before and after values | | High-value transactions | Payments, refunds, API key creation | Amount or scope, actor, request ID |
Log failures as carefully as successes; a burst of 403 responses from one account is the clearest signal of probing you will get.
Free-text log lines are hard to search and impossible to alert on reliably. Emit one JSON object per event with consistent field names, and attach a request ID to every line so a single request can be traced across services. The sample code does both with pino; the output looks like:
{"level":30,"time":1760000000000,"service":"shop-api","requestId":"5d2c…","ip":"203.0.113.9","event":"auth.login","outcome":"failure","email":"ada@example.com"}Send the request ID back in a response header so support tickets can be matched to log lines, and forward it to downstream services in the same header.
Two things must never reach the log store: secrets and attacker-controlled formatting.
authorization and cookie headers, password, token, card numbers). Never log full request bodies by default.\r and \n first.const safe = (s) => String(s).replace(/[\r\n]/g, " ").slice(0, 200);fs.appendFileSync("audit.txt", `${new Date().toISOString()} login ${safe(req.body.email)}\n`);A log nobody reads is a compliance artefact, not a control. Ship logs to a central system (a SIEM or a hosted log platform), then define alerts on the patterns that matter:
Route alerts to a channel someone actually watches, and test them by triggering each condition deliberately. Keep logs for at least 90 days (longer where regulation requires) in a separate account or system the application cannot modify, so an attacker who gains application access cannot erase their footprints. When an incident happens, "when did it start, which accounts, what was touched" are all answered from these logs filtered by request ID, user ID and time window.
Why is structured JSON logging preferred over free-text lines for security events?
Next lesson: Server-Side Request Forgery (SSRF) — when your server is tricked into making requests on the attacker's behalf.