Test and operate

Monitor web abuse

Create actionable signals for access failures, suspicious input, unusual data movement, configuration changes, and administrative actions.

A request you blocked still tells you something: someone is trying. One denied read is a user clicking a stale link. Five hundred denied reads across sequential IDs from one session is an attacker walking your database. The job is to catch that signal without writing the dangerous payload, or the password someone tried, into your logs.

Log the fact, not the secret

Log security-relevant events as structured data, and think about every field:

log.warn('authz.denied', {
  actorId: req.user?.id,        // safe identifier, not the raw token
  action: 'note.read',
  targetId: req.params.id,
  outcome: 'denied',
  ip: req.ip,
})

The line that comes out looks like this:

{"level":"warn","event":"authz.denied","actorId":"u_8f2a","action":"note.read","targetId":"43","outcome":"denied","ip":"203.0.113.9"}

Notice what’s not there. No session token, no request body, no query string. A log line that contains the XSS payload someone submitted can trigger that payload again in whatever dashboard renders the logs. A log line with a password from a failed login is a password leak waiting for the next log export. Record that the attempt happened, and who made it. Leave the ammunition out.

Alert on patterns, not on every denial

If you page someone for each authz.denied, they’ll mute the alert by Wednesday. Aggregate first, then alert on the shape that means trouble:

alert: object-enumeration
when:  authz.denied grouped by actorId
       count > 100 within 5m
       across > 20 distinct sequential targetIds
route: security-oncall  (link: dashboard + runbook)

A user who mistypes a URL twice never gets near that threshold. A script iterating /notes/1 through /notes/500 trips it in the first minute. Whatever tool you use to query logs, this is a GROUP BY actorId with a count and a distinct count over a five-minute window.

Do the same for the other events worth watching: rate-limit hits, upload rejections, blocked outbound fetches, and admin changes. Each gets its own aggregate and its own threshold.

Make every alert actionable

An alert has to tell the person who receives it what to do. That means a safe actor ID, the operation, the target pattern, the time window, the outcome, and two links: one to a dashboard showing the traffic and one to a runbook that says what to check first. An alert with no owner and no next step is noise, and people learn to ignore noise fast.

Watch data movement and configuration too, not just failures. A single export of 200,000 rows at 3 in the morning, a new admin role granted, or a burst of blocked outbound fetches from one worker each deserve their own signal. Attackers who already have access don’t generate denials.

Try this on your own project: in a test environment, make a few normal mistakes, like opening a note you don’t own twice. Then run a loop that reads two hundred sequential notes as another user. The first should stay below the threshold. The second should produce exactly one alert, with an owner and a link to investigate.

Lesson completed