A09:2025 Security Logging and Alerting Failures
A09:2025 Security Logging and Alerting Failures: which events to log, JSON logs without secrets, log injection, and alert rules that reach a person.
TLDR: Attacks you cannot see last longer. Log every security decision (logins, access control failures, input validation failures, admin actions) as structured JSON with standard event names and a request ID; keep passwords, tokens and personal data out of the logs; ship them somewhere attackers cannot edit; and, most important in the 2025 edition, alert on them. A log that nobody watches does not detect anything.
The category kept ninth place in 2025, renamed from "Security Logging and Monitoring Failures" to Security Logging and Alerting Failures. The A09:2025 page explains the change: great logging with no alerting is of minimal value. The category is hard to test for, so it has few CVEs, and it made the list through the community survey for the third time. Its CWEs cover insufficient logging (CWE-778), sensitive data in logs (CWE-532) and missing output encoding in logs (CWE-117).
Prerequisites
- The companion app from the guide introduction, and a terminal in its directory.
jqto read JSON logs.
What to Log
OWASP's first prevention point: log all login, access control and server-side input validation failures with enough context to identify the account, and keep them long enough for an investigation that starts weeks later. In practice, log every place where a security control makes a decision, success or failure:
- authentication: logins, failed logins, requests refused by a rate limit, requests with an invalid or expired token, logouts, password changes and resets, MFA changes;
- authorization: every denied request;
- input validation failures on security-relevant fields, such as a refused URL in the SSRF check;
- administrative actions and changes to permissions, keys and configuration;
- high-value business transactions, such as transfers and refunds, with an audit trail; the companion app writes each transfer to an audit table in the same database transaction and logs a
transfer_createdevent; - application start, stop and errors.
Use consistent event names, so queries and alerts do not depend on message wording. The companion app uses names from the OWASP Logging Vocabulary Cheat Sheet, such as authn_login_fail:alice, authz_fail:alice,invoice 1002 and session_logout:bob, and its own names in the same style where the vocabulary has none, such as authn_token_invalid and transfer_created.
Structured Logs, Without Secrets
The companion app logs JSON lines with pino: one event per line, with a timestamp, a level, the service name and the event fields. A redaction list replaces secrets before the line is written:
import pino from 'pino';
// JSON lines on stdout: one event per line, ready for a log pipeline.
// Secrets are replaced before the line is written.
export function createLogger(destination) {
return pino(
{
base: { service: 'invoices' },
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'password',
'*.password',
'token',
'*.token',
],
censor: '[REDACTED]',
},
},
destination
);
}
Run a few requests that the earlier parts showed, a failed login, an access to another user's invoice and a refused preview URL, and read the security events back:
Levels are numbers in pino: 30 is info and 40 is warn. Note what is missing: the wrong password, the session token and the request bodies. The redaction list is a safety net for fields that end up in log calls by accident:
Do not rely on it alone. Log specific fields instead of whole request objects, never log request bodies of authentication routes, and treat personal data in logs as personal data: subject to retention limits and access control.
Log Injection
If logs are plain text lines, a user name containing a line break can forge a second, fake log line, and an attacker can hide activity or plant misleading entries. JSON logging prevents that, because the serializer escapes control characters inside the string:
The fake event is inside the event string of a failed login, still one line, and a query on the event field will not mistake it for a real admin login. (The grep -c counts it, because grep reads text, not JSON fields; your log platform's parsed fields are what alerts should use.) If you must write plain text logs, encode or strip CR and LF in every user-supplied value. Also escape log data shown in log viewers and dashboards, since they are web pages too.
Alerts That Someone Acts On
Detection needs a rule that turns events into a page or a ticket, and a person or team who owns it. With logs in a central system, alert rules are queries. These Loki rules from the companion repository fire on a client that keeps trying after the login rate limit refused it (the limiter logs each refusal as authn_login_fail_max), on a spike of failed logins across all accounts (credential stuffing from many addresses, each below the per-address limit), and on repeated access control failures from one address, a sign of someone walking through IDs:
# A09 example: Loki alerting rules for the invoices app's JSON logs. They assume
# the log shipper sets the stream label service="invoices". Label regexes match the
# whole value and "." skips line breaks, so (?s) keeps a user name containing a line
# break from hiding an event.
groups:
- name: invoices-security
rules:
- alert: LoginLimitHammered
# authn_login_fail_max is logged for every request the rate limiter refuses
expr: |
sum by (ip) (
count_over_time({service="invoices"} | json | event =~ "(?s)authn_login_fail_max:.*" [15m])
) > 10
labels:
severity: page
annotations:
summary: '{{ $labels.ip }} keeps trying to log in after hitting the rate limit'
runbook: 'https://wiki.example.com/runbooks/credential-stuffing'
- alert: LoginFailuresAcrossAccounts
expr: |
sum(count_over_time({service="invoices"} | json | event =~ "(?s)authn_login_fail:.*" [15m])) > 200
labels:
severity: page
annotations:
summary: 'Failed logins across all accounts are unusually high: possible credential stuffing'
- alert: AccessDeniedBurst
expr: |
sum by (ip) (
count_over_time({service="invoices"} | json | event =~ "(?s)authz_fail:.*" [10m])
) > 10
labels:
severity: ticket
annotations:
summary: 'More than 10 access control failures from {{ $labels.ip }} in 10 minutes'
The (?s) at the start of each regular expression matters: Loki matches the whole label value, and . does not match a line break, so without it a user name with a line break, like the one in the log injection example above, would hide its event from the alert. The rules pass lokitool rules lint and lokitool rules check. Even without a log platform you can run the same query by hand. From the session above:
Good alerts have:
- an owner and a runbook: who responds, and what they check first;
- thresholds from data: start from your normal rate of failures, not a guess, and tune them so that alerts stay rare enough to be read; OWASP lists alert floods that hide real attacks as a failure in this category;
- a test: run the attack you want to detect against staging, for example a scripted series of failed logins, and confirm the alert fires. OWASP calls out penetration tests and DAST scans that trigger no alert as a failure; a passive scan such as the ZAP baseline sends no attacks, so check your active scans instead;
- honeytokens: fake credentials or records that no legitimate process uses, such as an API key in the database that is never issued. Any use of one is an attack, with almost no false positives.
Protect the Logs
- Ship logs off the host as they are written. An attacker who controls a server can edit local files.
- Make audit trails append-only: a separate account or project for logs, object storage with a retention lock, or append-only database tables for business transactions.
- Limit access to logs, and log access to them.
- Keep clocks synchronized and timestamps in UTC, so events from different systems line up.
- Keep the request ID in every event of a request, and return it in error responses (A02), so a support ticket leads straight to the logs.
When an alert fires, follow an incident response plan. OWASP points to NIST SP 800-61; its third revision, published in April 2025, aligns incident response with the NIST Cybersecurity Framework 2.0.
The logging tests check the redaction and encoding behavior:
Common Mistakes
- Logging only successes, or only failures, for logins.
- Free-text messages that alerts must parse with regular expressions.
- Secrets and personal data in logs: passwords, tokens, full request bodies.
- Logs only on the host where the attacker is.
- Alerts without an owner, or so many alerts that nobody reads them.
- Never testing detection: run the attack you want to detect, and check that the alert fires.
Summary
- Log every security decision with a standard event name, the account, the client address and a request ID.
- Use structured JSON logs with redaction; never log credentials or request bodies of sensitive routes.
- JSON encoding stops log injection; escape log data wherever it is displayed.
- Write alert rules with owners, runbooks and tested thresholds, and add honeytokens.
- Ship logs off the host into append-only storage, and keep an incident response plan.
Next, A10:2025 Mishandling of Exceptional Conditions, the new category, covers what happens when something goes wrong in the middle of a request.
Keep Learning
Found an issue?