GUIDE · OWASP Top 10:2025
A09:2025 Security Logging and Alerting Failures
Breaches go undetected or uninvestigable because the right events aren't logged, logs are unsafe/untrustworthy, or nobody is alerted.
At a glance
- WHAT
- Breaches go undetected or uninvestigable because the right events aren't logged, logs are unsafe/untrustworthy, or nobody is alerted.
- HAPPENS
- auth failures / access-control denials not logged; logs contain passwords, tokens, PII, card numbers; log injection (CRLF forges fake entries, breaks parsers); logs only local (attacker wipes them); no alert thresholds (10k failed logins at 3am, silence); no correlation/request ids; retention too short for an investigation; unclear time zones / unsynced clocks; log pipeline itself is a DoS vector (unbounded field sizes).
- SPOT
- console.log(req.body); logger.info(
user ${name} logged in) with raw user strings; no audit log for admin actions; no SIEM / alerting rules; logs written to the same box as the app only; errors swallowed with empty catch. - FIX
- structured JSON logs; log: auth success/failure, authZ denials, input validation failures, high-value txns, admin actions, config changes; never log secrets — redact by key AND pattern; strip CR/LF and control chars; correlation id per request; ship to centralized append-only store (SIEM); alert on thresholds and anomalies; retention policy (e.g., 90d hot / 1y cold); UTC timestamps; tamper-evidence (hash chain / WORM storage).
- SAY
- CWE-778 Insufficient Logging, CWE-117 Improper Output Neutralization for Logs (log injection), CWE-532 Insertion of Sensitive Information into Log File, CWE-223 Omission of Security- relevant Information. SIEM, SOC, MTTD/MTTR, audit trail, correlation id, WORM. Pitch: "Log the security events, not the secrets; make logs structured, central, tamper-evident, and wired to alerts someone actually reads."
The idea#
Picture a hall monitor with a notebook. The job is to write who tried the wrong door, and to ring a bell when the same kid tries ten times in a minute. The combination of the lock stays out of the notebook. The pages are chained, so a page someone rewrote no longer matches the page before it. The notebook lives in the office. A student cannot carry it home and throw it out.
That picture, done badly, is Security Logging and Alerting Failures. The record is missing, full of secrets, easy to forge, or never read. Log security events as one JSON object per line, strip secrets, and store the lines where the app cannot quietly erase them. Turn newlines inside a field into spaces. Replace secret-shaped keys and secret-shaped text with a redaction mark. Cap the length. Put a request id and a UTC time on every line. Raise an alert when a count crosses a limit inside a window. Chain a hash from line to line so a rewrite is visible.
Failure looks like the notebook left on the bench. Failed logins never get a line. The line contains the password and the card. A name with a line break pretends to be a second event that says an admin succeeded. The files sit on the same box as the app and disappear with it. A huge burst of failures at three in the morning pages nobody. Clocks disagree, so the story of one visit cannot be sorted. One enormous field knocks the logging pipeline over.
Why it matters#
You learn about a break-in from the logs, or you learn about it from a stranger. This 2025 category covers the writing and the alerting. An empty notebook cannot say whether anyone spent the night on the door. A notebook full of secrets is another copy of the secrets. An alert that never rings means nobody reads the notebook.
Write one JSON object per line, and redact by key name and by pattern before you ship it. Copy one correlation id onto every line from that request. Send the lines to a central store, keep them long enough to investigate, stamp UTC, and sync the clocks. Alert on thresholds. Chain each line to the hash before it.
A small example#
function cleanString(value) { return String(value).replace(/[\r\n]/g, ' ');} function redact(fields) { const secretKey = /^(password|token|authorization|cookie)$/i; const out = {}; for (const [key, value] of Object.entries(fields)) { out[key] = secretKey.test(key) ? '[REDACTED]' : cleanString(value); } return out;} const entry = redact({ username: 'alice', password: 'placeholder',});entry.username stays alice. entry.password becomes [REDACTED]. A line break inside a normal string value is replaced with a space, so that field cannot start a second record.
What the code below is doing#
Four sections: how a line is built, which events belong in the book, when the bell rings, and how a rewritten page gives itself away.
The logger is SecurityLogger, with cleanString and redact in front of it. Secret key names, including password, token, cookie, card, and a few spellings, become [REDACTED]. Patterns replace long digit runs, JWT-shaped text, bearer tokens, and a simple national-id shape. Strings stop at 512 characters. event writes one JSON line with a UTC time, the event name, and a request id. The asserts keep a line-break username inside a single failure record, redact nested secrets, scrub a free-text note, and shorten a huge field.
The catalog is two frozen lists. SECURITY_EVENTS is what you record, including login, authz.denied, admin actions, and rate-limit hits. NEVER_LOG keeps out passwords, session ids, tokens, full card numbers, extra personal details, and whole request bodies.
Alerting is ThresholdAlert. record returns true once the hits inside the window reach the limit. Ten failures from one address fire. Another address stays quiet. After the window slides, the first address is quiet again.
The chain is chainHash. Each line is hashed with the previous hash. Replaying the lines reproduces the head. Changing one word produces a different head. The closing note is retention, UTC, and synced clocks.
When you talk about it, say this: log the security events, leave the secrets out, keep every line one JSON object in a store that shows tampering, and ring an alert a person reads.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');const crypto = require('node:crypto'); Not run in the browser: uses node:crypto. Copy the node command and run it locally.
Structured logger: redaction by key + pattern, newline/control-char stripping#
// VULNERABLE:// console.log(`login attempt for ${req.body.username}`); // username = "bob\n[INFO] admin login OK"// logger.info('request', req.body); // { password: 'hunter2', card: '4111...' }// FIXED:const SECRET_KEYS = /^(pass(word)?|pwd|secret|token|authorization|cookie|api[-_]?key|ssn|card(number)?|cvv|refresh_token|access_token)$/i;const PATTERNS = [ [/\b(?:\d[ -]?){13,19}\b/g, '[CARD]'], // PAN-like digit runs [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[JWT]'], [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'], [/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'],];const MAX_FIELD = 512;function cleanString(s) { let out = String(s).replace(/[\r\n\t\u0000-\u001f\u007f\u2028\u2029]/g, ' '); // no forged lines for (const [re, rep] of PATTERNS) out = out.replace(re, rep); if (out.length > MAX_FIELD) out = out.slice(0, MAX_FIELD) + '…[truncated]'; return out;}function redact(value, depth = 0) { if (depth > 8) return '[depth]'; if (typeof value === 'string') return cleanString(value); if (Array.isArray(value)) return value.slice(0, 100).map((v) => redact(v, depth + 1)); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) out[cleanString(k)] = SECRET_KEYS.test(k) ? '[REDACTED]' : redact(v, depth + 1); return out; } return value;}class SecurityLogger { constructor(sink) { this.sink = sink; } event(type, fields, ctx = {}) { const entry = { ts: new Date(0).toISOString(), // real code: new Date().toISOString() — UTC always level: fields.level || 'info', event: cleanString(type), requestId: ctx.requestId || crypto.randomUUID(), ...redact(fields), }; this.sink(JSON.stringify(entry)); // one JSON object per line; parsers can't be confused return entry; }}const lines = [];const log = new SecurityLogger((line) => lines.push(line)); // log injection attemptconst e1 = log.event('auth.login.failure', { username: 'bob\r\n{"event":"auth.login.success","username":"admin"}', ip: '203.0.113.9' }, { requestId: 'r-1' }); // secret redaction by keyconst e2 = log.event('auth.login.attempt', { username: 'alice', password: 'hunter2', headers: { Authorization: 'Bearer abc.def.ghi', Cookie: 'sid=1' }, nested: { apiKey: 'k-123' } }); // pattern redaction inside free textconst jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c';const e3 = log.event('payment.declined', { note: `card 4111 1111 1111 1111 declined; token ${jwt}; auth Bearer xyz123; ssn 123-45-6789` }); // correlation id + bounded field sizeVerified by 15 asserts
assert.equal(lines.length, 1); // still exactly one lineassert.equal(lines[0].includes('\n'), false);assert.equal(e1.username.includes('\n'), false);assert.equal(JSON.parse(lines[0]).event, 'auth.login.failure'); // forged "success" is inside a string, not a recordassert.equal(e2.password, '[REDACTED]');assert.equal(e2.headers.Authorization, '[REDACTED]');assert.equal(e2.headers.Cookie, '[REDACTED]');assert.equal(e2.nested.apiKey, '[REDACTED]');assert.equal(JSON.stringify(e2).includes('hunter2'), false);assert.equal(e3.note.includes('4111'), false);assert.equal(e3.note.includes(jwt), false);assert.match(e3.note, /\[CARD\].*\[JWT\].*Bearer \[REDACTED\].*\[SSN\]/);assert.equal(e1.requestId, 'r-1');assert.match(e2.requestId, /^[0-9a-f-]{36}$/);assert.ok(log.event('x', { big: 'A'.repeat(10_000) }).big.length < 600);What to log (event catalog) vs never log#
const SECURITY_EVENTS = Object.freeze([ 'auth.login.success', 'auth.login.failure', 'auth.logout', 'auth.mfa.failure', 'auth.password.reset', 'authz.denied', // access-control failures: the IDOR probe signal 'input.validation.failure', // injection probes 'txn.high_value', 'admin.action', 'config.change', 'session.rotated', 'ratelimit.triggered',]);const NEVER_LOG = Object.freeze(['passwords', 'session ids', 'tokens / API keys', 'full card numbers / CVV', 'PII beyond a stable user id', 'request bodies wholesale']);Verified by 1 assert
assert.ok(SECURITY_EVENTS.includes('authz.denied') && NEVER_LOG.length >= 5);Alerting thresholds (sliding window counter)#
the "alerting" half of the category
class ThresholdAlert { constructor(limit, windowMs) { this.limit = limit; this.windowMs = windowMs; this.hits = new Map(); } record(key, now) { const arr = (this.hits.get(key) || []).filter((ts) => now - ts < this.windowMs); arr.push(now); this.hits.set(key, arr); return arr.length >= this.limit; // true => page someone }}const failedLogins = new ThresholdAlert(10, 60_000);let t = 0;let fired = false;for (let i = 0; i < 10; i++) fired = failedLogins.record('ip:203.0.113.9', t += 100);Verified by 3 asserts
assert.equal(fired, true);assert.equal(failedLogins.record('ip:198.51.100.1', t), false);assert.equal(failedLogins.record('ip:203.0.113.9', t + 61_000), false); // window slidTamper-evident log chain (what centralized WORM / hash-chained storage gives you)#
function chainHash(prevHash, line) { return crypto.createHash('sha256').update(prevHash + '\n' + line).digest('hex'); }let head = '0'.repeat(64);const chain = lines.map((line) => (head = chainHash(head, line)));const replay = lines.reduce((h, line) => chainHash(h, line), '0'.repeat(64));const tamperedLines = [...lines]; tamperedLines[0] = tamperedLines[0].replace('failure', 'success');// Retention: keep security logs long enough to investigate (commonly 90d searchable, 1y+ archived); clocks via NTP; UTC.Verified by 2 asserts
assert.equal(replay, chain.at(-1));assert.notEqual(tamperedLines.reduce((h, line) => chainHash(h, line), '0'.repeat(64)), chain.at(-1));See also