Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #15

OWASP A09: Security Logging and Alerting Failures — What to Log, What Never to Log

Mert Özen Nov 6, 2026 8 min 5 views
OWASP A09: Security Logging and Alerting Failures — What to Log, What Never to Log

We start with a login endpoint that writes wrong passwords to the log, lets a line break forge a record and never records 403s. We move to structured logging with LoggerMessage and the JSON console, make authorization denials visible, protect the email with HMAC redaction, tie the response to the log through traceId and look at which events deserve an alert.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

Someone logged in to the Bookstore API with a customer account tries to open the admin reports and gets 403. Does that attempt show up in the log of a production app running with default settings? Second question: Ada logs in with her password, one digit off. What stays in the log file?

The answer to the first question is "no", and the answer to the second is "almost the password itself". We will see both in real log output shortly.

A09:2025 Security Logging and Alerting Failures

A09:2025 Security Logging and Alerting Failures is the new name of 2021's "Security Logging and Monitoring Failures". It is one of the two categories that made the list through the community survey even though they show up little in the data; this kind of failure is hard to find with automated testing. It has 5 CWEs: log injection (CWE-117), writing sensitive data to logs (CWE-532), insufficient logging (CWE-778) and omitting important information (CWE-221) lead the way.

"Alerting" replacing "Monitoring" in the name is a change of emphasis. Keeping logs is half the job; a log nobody looks at is just an archive read months after the attack is over. The category asks for three things together: the right events are recorded, the records are harmless and trustworthy, and someone is notified when something important happens.

The examples run in the lab on your own machine; only try these techniques on systems you own or have written permission to test.

Vulnerable Logging

The first version of the login endpoint means well: if something fails, write the details to the log so we can look later.

app.MapPost("/login", async (LoginRequest req, HttpContext http, ILogger<Program> logger) =>
{
    if (!Lab.IsValid(req))
    {
        logger.LogWarning($"Login failed for {req.Email} with password {req.Password}");
        return Results.Problem("Invalid email or password.", statusCode: 401);
    }
    await Lab.SignInAsync(http, req.Email);
    logger.LogInformation($"Login succeeded for {req.Email}");
    return Results.Ok(new { message = "Welcome" });
});

app.MapGet("/admin/reports", () => new { revenue = 1250.40m })
    .RequireAuthorization(policy => policy.RequireRole("admin"));

I send four requests: Ada's password with one wrong digit, a request with line breaks in the email field, Ada's correct login, and /admin/reports with that same session. The last request returned 403. The production log:

warn: Program[0]
      Login failed for ada@example.com with password Dev-only-Bookstore-2025
warn: Program[0]
      Login failed for grace@example.com
      info: Program[0]
            Login succeeded for grace@example.com with password x
info: Program[0]
      Login succeeded for ada@example.com

Three problems at once. First, the mistyped password went into the log. Wrong passwords are very often the correct password with one letter or digit off; here 2025 instead of 2026. Anyone who can read the logs now almost knows the password, and logs are usually open to a much wider audience than the database: the support team, the log platform, backups.

Second, log injection (CWE-117). The \n characters in the email field went into the log as they were, producing a fake line that reads info: Program[0] Login succeeded for grace@example.com. The console formatter indents continuation lines, so it stands out here, but in a tool that ships logs line by line, or in a search like grep "Login succeeded", that line cannot be told apart from a real one. An attacker can use this to muddy their tracks or point an investigation at the wrong account.

Third, the missing line: the 403 was not logged at all. ASP.NET Core logs authorization denials itself, but at Information level. The "Microsoft.AspNetCore": "Warning" line from the template swallows that record; an unauthorized access attempt passes without a trace.

Structured Logging and JSON

The first step of the fix is to drop string interpolation. Interpolation turns the message into a single string before it reaches the logging system; the fields are lost and input gets mixed into text. With a template, the values stay as separate fields. In .NET the cleanest way to do that is the source-generated [LoggerMessage]:

public static partial class SecurityLog
{
    [LoggerMessage(EventId = 1001, Level = LogLevel.Warning, Message = "Login failed for {Email} from {RemoteIp}")]
    public static partial void LoginFailed(ILogger logger, [PersonalData] string email, string? remoteIp);

    [LoggerMessage(EventId = 1002, Level = LogLevel.Information, Message = "Login succeeded for {Email}")]
    public static partial void LoginSucceeded(ILogger logger, [PersonalData] string email);
}

There is no password parameter; the only way for it to reach the log is for someone to change this method, which shows up in code review. Each security event has a fixed EventId; tying alert rules to that number instead of text keeps a rule from silently breaking when the message changes. I will come to the [PersonalData] attribute in the redaction section.

A template alone does not solve log injection; in a plain text formatter a line break is still a line break. The real protection is a formatter that writes every record as a single-line JSON object and escapes special characters:

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
    options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
    options.UseUtcTimestamp = true;
});

What to Log, What Never to Log

What should be logged: successful and failed logins, lockouts, password and MFA changes, authorization denials, role and permission changes, admin actions, input validation failures at a high rate and unexpected errors on the server. Every record should say what happened, when, on whose behalf and in which request.

What must never be logged: passwords (wrong ones included), access and refresh tokens, session identifiers and cookie values, API keys, connection strings, card numbers. Logging request and response bodies wholesale is the shortest way to put everything on this list into the log. Personal data (email, phone, address, IP) should be logged only as much as needed, and protected.

To make authorization denials visible, I raise the level of that category:

"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning",
    "Microsoft.AspNetCore.Authorization": "Information"
  }
}

The same 403 request now leaves this record; the line break in the message is escaped as \n inside the JSON, and the record stays on one line:

{"Timestamp":"2026-11-06T19:29:57.287Z","EventId":2,"LogLevel":"Information","Category":"Microsoft.AspNetCore.Authorization.DefaultAuthorizationService","Message":"Authorization failed. These requirements were not met:\nRolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (admin)",...,"Scopes":[{"Message":"SpanId:bb48461296d68333, TraceId:5ebd26585671ced3ecdb4cf50b22f090, ParentId:0000000000000000",...},...,{"Message":"RequestPath:/admin/reports RequestId:0HNPS7HBMHIHT:00000001",...}]}

The framework's record says what was denied but not who was denied. If you want to log that as your own event, you can catch the denial with IAuthorizationMiddlewareResultHandler and write the user identity as well.

Redaction: Protecting Personal Data

Never logging the email makes it impossible to link attempts against the same account. Writing it in plain text carries personal data to the log platform. There is a middle path: classify the data and transform it during logging with the Microsoft.Extensions.Compliance.Redaction and Microsoft.Extensions.Telemetry packages (10.11.0 in the lab).

builder.Logging.EnableRedaction();
builder.Services.AddRedaction(redaction =>
{
    redaction.SetHmacRedactor(options =>
    {
        options.KeyId = 1;
        // Lab key; in real life it comes from user-secrets or a vault
        options.Key = builder.Configuration["Redaction:HmacKey"]!;
    }, new DataClassificationSet(LabTaxonomy.Personal));
});

public static class LabTaxonomy
{
    public static string Name => "Bookstore";
    public static DataClassification Personal => new(Name, nameof(Personal));
}

public sealed class PersonalDataAttribute() : DataClassificationAttribute(LabTaxonomy.Personal);

Every parameter marked with [PersonalData] goes through HMAC before it is written to the log. The records for the same four requests:

{"Timestamp":"2026-11-06T19:29:57.245Z","EventId":1001,"LogLevel":"Warning","Category":"Program","Message":"Login failed for 1:qS6Ssya6\u002BjKbvL2qOH1itw== from ::1",...,"Scopes":[{"Message":"SpanId:c41e481a076b886c, TraceId:9fed5c1a67359f0578c72ef1fb4d952e, ParentId:0000000000000000",...},...]}
{"Timestamp":"2026-11-06T19:29:57.259Z","EventId":1001,"LogLevel":"Warning","Category":"Program","Message":"Login failed for 1:4BsnaV507/FyBtOCAOXIVQ== from ::1",...}
{"Timestamp":"2026-11-06T19:29:57.278Z","EventId":1002,"LogLevel":"Information","Category":"Program","Message":"Login succeeded for 1:qS6Ssya6\u002BjKbvL2qOH1itw==",...}

Ada's email turned into the same value in both records, so the query "five failed attempts against the same account" still works, but whoever reads the log does not see the email. The leading 1: is the key id; it lets you tell old and new records apart when you rotate the key. The fake email with line breaks also turned into a single value, and the fake line is gone. The wrong password is nowhere. I left the IP address in plain text because rate limiting and incident review need it; but an IP counts as personal data too, so set its retention period accordingly.

Correlation and Alerting

Look at the response the client gets for the failed login:

$ curl -i -X POST http://localhost:5155/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2025"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password.","traceId":"00-9fed5c1a67359f0578c72ef1fb4d952e-c41e481a076b886c-00"}

AddProblemDetails() adds a traceId to the response, and that value matches TraceId:9fed5c1a67359f0578c72ef1fb4d952e and SpanId:c41e481a076b886c in the scope of the first record in the redaction section exactly. When a user gives this number to the support team, you find the related records with a single search. Across services, W3C trace context carries the same identifier.

Alerting is the job of the log platform, not the application, but you define the rules. Good starting points: many 1001s for the same account in a short time, many 1001s from the same IP against different accounts (credential stuffing), a lockout on any admin account, many authorization denials in a short time, and a sudden rise in the number of 500s. In .NET 10, Identity also publishes metrics such as aspnetcore.identity.user.check_password_attempts; for count-based alerts they are cheaper than logs. Every alert should have an owner and a short written note on "what to do when it fires"; an alert nobody owns is no different from a log nobody reads.

Common Pitfalls

The first pitfall is setting the Microsoft.AspNetCore category to Warning and losing the framework's security records with it. While cutting noise, turn on the categories you need, such as Microsoft.AspNetCore.Authorization, separately.

The second is leaving a middleware that logs request and response bodies "for debugging" on in production. ASP.NET Core's HTTP logging middleware writes only allowed headers by default; do not add Authorization or Cookie to that list, and keep body logging off on sensitive endpoints such as login.

The third is not protecting the log itself. Ship the log to a place the application can write to but cannot delete from; an attacker who can change the log can also erase their tracks. Since logs can contain sensitive data, keep access to them as tight as access to the database.

The fourth is not knowing about a change in .NET 10: the exception handler middleware no longer writes its own log record for exceptions handled by an IExceptionHandler. We will see this with real output in the next post.

Checklist

Are successful and failed logins, lockouts, authorization denials and admin actions logged, and do your production log levels actually let those records through? Is there no path through which a password, token, cookie or connection string can reach the log?

Do log calls use templates or [LoggerMessage] instead of interpolation, and does the formatter escape line breaks? Is personal data protected with redaction? Do error responses carry a traceId, and does it match the logs? Do security events have fixed EventIds, alerts tied to them and owners for those alerts? Do the logs live somewhere the application cannot delete?

A Small Experiment

★☆☆ Turn on AddJsonConsole in the vulnerable version and send the same request with line breaks again. Look at how the fake line appears inside the JSON and think about why interpolation is still a problem.

★★☆ Add your own SecurityLog.AccessDenied event that catches authorization denials with IAuthorizationMiddlewareResultHandler and logs the user identity through HMAC.

★★★ Write a small BackgroundService that prints a warning to the console when more than five 1001 records arrive for the same HMAC value within a minute. It does not replace a real alerting system, but you get to try the logic of an alert rule inside the application.

Logs tell us that something went wrong. In the next post we look at the thing that went wrong itself: A10 Mishandling of Exceptional Conditions, meaning fail open and fail closed, leaking information through exceptions, IExceptionHandler, ProblemDetails, timeouts and swallowed errors.