RO EN

Audit Logging and Security Events in ASP.NET Core

Audit Logging and Security Events in ASP.NET Core
Doru Bulubașa
29 May 2026

All previous articles in this series have focused on prevention: strong authentication, proper authorization, protection against known attacks. But prevention is never 100%. The question is not if you will have a security incident, but when — and how quickly you will detect and understand it.

Audit logging records what happened in the system: who did what, when, from where, and with what result. Security events are a special subset — events with security implications: failed authentications, access to sensitive resources, permission changes, administrative operations. Together they form the basis for detection, investigation, and compliance.


1. What must be logged — and what not

The first instinct is to log everything. Mistake — a huge volume of unstructured logs is as useless as no logs. You must log events with investigative value.

Must be logged mandatorily

  • Successful and failed authentications (with IP, user agent, timestamp)
  • Explicit logout and session expiration
  • Password and email changes
  • MFA activation / deactivation
  • Account creation, modification, and deletion
  • Role and permission changes
  • Access to sensitive data (financial data, personal data, confidential documents)
  • Administrative operations (system configuration, data export, mass deletions)
  • Authorization errors (403 Forbidden) — indicate unauthorized access attempts
  • Rate limit exceeded (429) — may indicate automated attack
  • Changes to critical entities in the database

Never log

  • Passwords or password hashes
  • Authentication tokens (JWT, refresh tokens, API keys)
  • Bank card data or CVV
  • Full content of requests with sensitive data (bodies with passwords, PII)
  • Session cookies

Golden rule: an audit log must be able to answer the question “who did X at time T?” — without exposing data that can be exploited if logs are compromised.


2. Structure of an audit log

A useful audit log has a consistent structure that allows filtering, correlation, and automatic alerting. Essential fields:

public record AuditEvent
{
    // Event identity
    public Guid   EventId        { get; init; } = Guid.NewGuid();
    public string EventType      { get; init; } = default!; // "auth.login.success"
    public string Category       { get; init; } = default!; // "Authentication"
    public string Severity       { get; init; } = default!; // "Info", "Warning", "Critical"

    // Who
    public string? UserId        { get; init; }
    public string? UserName      { get; init; }
    public string? UserEmail     { get; init; }
    public string? IpAddress     { get; init; }
    public string? UserAgent     { get; init; }

    // What
    public string  Action        { get; init; } = default!; // "Login"
    public string? ResourceType  { get; init; }             // "Invoice"
    public string? ResourceId    { get; init; }             // "INV-2025-001"
    public bool    Succeeded     { get; init; }
    public string? FailureReason { get; init; }

    // Context
    public string? RequestPath   { get; init; }
    public string? HttpMethod    { get; init; }
    public string? CorrelationId { get; init; } // links multiple events from a request
    public string? TenantId      { get; init; } // for multi-tenant architectures

    // When
    public DateTime Timestamp    { get; init; } = DateTime.UtcNow;

    // Additional structured data
    public Dictionary? AdditionalData { get; init; }
}

Convention for EventType

Use a hierarchical, consistent naming convention — facilitates filtering and alerting:

// Format: <domain>.<action>.<result>
"auth.login.success"
"auth.login.failed"
"auth.logout"
"auth.password.changed"
"auth.mfa.enabled"
"auth.mfa.disabled"

"account.created"
"account.deleted"
"account.role.assigned"
"account.role.revoked"

"data.invoice.viewed"
"data.invoice.exported"
"data.user.personal.accessed"

"admin.config.changed"
"admin.bulk.delete"
"admin.impersonation.started"

3. Audit logging service

3.1 Interface and implementation

public interface IAuditLogger
{
    Task LogAsync(AuditEvent auditEvent);
    Task LogAsync(string eventType, string action, bool succeeded,
        string? resourceType = null, string? resourceId = null,
        string? failureReason = null,
        Dictionary? additionalData = null);
}

public class AuditLogger(IHttpContextAccessor httpContextAccessor,
                         ILogger<AuditLogger> logger) : IAuditLogger
{
    public Task LogAsync(AuditEvent auditEvent)
    {
        // Log as structured log — Application Insights indexes it automatically
        logger.LogInformation(
            "[AUDIT] {EventType} | User: {UserId} | IP: {IpAddress} | " +
            "Action: {Action} | Resource: {ResourceType}/{ResourceId} | " +
            "Succeeded: {Succeeded} | Reason: {FailureReason}",
            auditEvent.EventType,
            auditEvent.UserId ?? "anonymous",
            auditEvent.IpAddress,
            auditEvent.Action,
            auditEvent.ResourceType,
            auditEvent.ResourceId,
            auditEvent.Succeeded,
            auditEvent.FailureReason);

        return Task.CompletedTask;
    }

    public Task LogAsync(string eventType, string action, bool succeeded,
        string? resourceType = null, string? resourceId = null,
        string? failureReason = null,
        Dictionary? additionalData = null)
    {
        var context = httpContextAccessor.HttpContext;
        var user    = context?.User;

        var auditEvent = new AuditEvent
        {
            EventType      = eventType,
            Action         = action,
            Category       = eventType.Split('.')[0],
            Severity       = succeeded ? "Info" : "Warning",
            UserId         = user?.FindFirstValue(ClaimTypes.NameIdentifier),
            UserName       = user?.FindFirstValue(ClaimTypes.Name),
            UserEmail      = user?.FindFirstValue(ClaimTypes.Email),
            IpAddress      = GetClientIp(context),
            UserAgent      = context?.Request.Headers.UserAgent.ToString(),
            ResourceType   = resourceType,
            ResourceId     = resourceId,
            Succeeded      = succeeded,
            FailureReason  = failureReason,
            RequestPath    = context?.Request.Path.ToString(),
            HttpMethod     = context?.Request.Method,
            CorrelationId  = context?.TraceIdentifier,
            AdditionalData = additionalData
        };

        return LogAsync(auditEvent);
    }

    private static string? GetClientIp(HttpContext? context)
    {
        if (context is null) return null;

        // Read real IP if behind a proxy
        var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
        if (!string.IsNullOrEmpty(forwardedFor))
            return forwardedFor.Split(',')[0].Trim();

        return context.Connection.RemoteIpAddress?.ToString();
    }
}

3.2 Registration in DI

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();

3.3 Usage in controllers

[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(
    [FromBody] LoginDto dto,
    [FromServices] IAuditLogger auditLogger)
{
    var result = await _signInManager
        .PasswordSignInAsync(dto.Email, dto.Password,
            isPersistent: false, lockoutOnFailure: true);

    if (result.Succeeded)
    {
        await auditLogger.LogAsync(
            eventType:    "auth.login.success",
            action:       "Login",
            succeeded:    true,
            additionalData: new()
            {
                ["email"] = dto.Email  // email, not password!
            });

        return Ok();
    }

    await auditLogger.LogAsync(
        eventType:     "auth.login.failed",
        action:        "Login",
        succeeded:     false,
        failureReason: result.IsLockedOut ? "Account locked" : "Invalid credentials",
        additionalData: new() { ["email"] = dto.Email });

    return Unauthorized();
}

4. Automatic audit logging with middleware and Action Filters

For CRUD operations on entities, you can automate audit logging with an Action Filter — without adding code in every controller.

public class AuditActionFilter(IAuditLogger auditLogger) : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var executedContext = await next();

        // Log only if [Audit] attribute exists on action/controller
        var auditAttr = context.ActionDescriptor
            .EndpointMetadata
            .OfType<AuditAttribute>()
            .FirstOrDefault();

        if (auditAttr is null) return;

        var succeeded = executedContext.Exception is null
            && executedContext.HttpContext.Response.StatusCode < 400;

        await auditLogger.LogAsync(
            eventType:    auditAttr.EventType,
            action:       auditAttr.Action,
            succeeded:    succeeded,
            resourceType: auditAttr.ResourceType,
            failureReason: succeeded ? null :
                $"HTTP {executedContext.HttpContext.Response.StatusCode}");
    }
}

// Custom attribute
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class AuditAttribute(
    string eventType,
    string action,
    string? resourceType = null) : Attribute
{
    public string  EventType    { get; } = eventType;
    public string  Action       { get; } = action;
    public string? ResourceType { get; } = resourceType;
}

// Global registration
builder.Services.AddControllers(options =>
{
    options.Filters.Add<AuditActionFilter>();
});
// Usage
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
[Audit("admin.invoice.deleted", "DeleteInvoice", "Invoice")]
public async Task<IActionResult> Delete(int id)
{
    await _invoiceService.DeleteAsync(id);
    return NoContent();
}

5. Serilog — structured logging for audit

Serilog is the most popular logger in the .NET ecosystem for structured logging. You configure it to send audit logs to multiple destinations simultaneously.

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Serilog.Sinks.AzureCosmosDB   // optional, for Cosmos DB
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
// Program.cs
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .MinimumLevel.Override("System",    LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .Enrich.WithEnvironmentName()
    .Enrich.WithThreadId()
    .Enrich.WithProperty("Application", "LudoProgramming")
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .WriteTo.ApplicationInsights(
        builder.Configuration["ApplicationInsights:ConnectionString"],
        TelemetryConverter.Traces)
    .CreateLogger();

builder.Host.UseSerilog();

Dedicated filter for audit events

Separate audit logs from operational logs — you want them in different destinations with different retention:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Logger(lc => lc
        // Operational logs — retention 30 days
        .Filter.ByExcluding(e =>
            e.MessageTemplate.Text.StartsWith("[AUDIT]"))
        .WriteTo.Console()
        .WriteTo.ApplicationInsights(connectionString,
            TelemetryConverter.Traces))
    .WriteTo.Logger(lc => lc
        // Audit logs — retention 1 year (GDPR compliance)
        .Filter.ByIncludingOnly(e =>
            e.MessageTemplate.Text.StartsWith("[AUDIT]"))
        .WriteTo.ApplicationInsights(connectionString,
            TelemetryConverter.Events)  // as Events, not Traces
    )
    .CreateLogger();

6. Audit log in database — persistent trail

For GDPR compliance and forensic investigations, audit logs must be stored persistently, separate from operational logs. Cosmos DB is an excellent choice — naturally append-only, scalable, and already in LudoProgramming infrastructure.

// Cosmos DB model for audit
public class AuditEventDocument : AuditEvent
{
    [JsonProperty("id")]
    public string Id { get; init; } = Guid.NewGuid().ToString();

    // Partition key: "userId" or "tenantId" for efficient queries
    [JsonProperty("partitionKey")]
    public string PartitionKey { get; init; } = default!;

    // TTL: Cosmos DB can automatically delete after retention period
    [JsonProperty("ttl")]
    public int? Ttl { get; init; }  // seconds; -1 = never expire
}

// Repository
public class CosmosAuditRepository(CosmosClient cosmosClient)
{
    private readonly Container _container = cosmosClient
        .GetContainer("AuditDb", "AuditEvents");

    public async Task AppendAsync(AuditEvent auditEvent)
    {
        var doc = new AuditEventDocument
        {
            EventType      = auditEvent.EventType,
            Action         = auditEvent.Action,
            Category       = auditEvent.Category,
            Severity       = auditEvent.Severity,
            UserId         = auditEvent.UserId,
            UserName       = auditEvent.UserName,
            IpAddress      = auditEvent.IpAddress,
            ResourceType   = auditEvent.ResourceType,
            ResourceId     = auditEvent.ResourceId,
            Succeeded      = auditEvent.Succeeded,
            FailureReason  = auditEvent.FailureReason,
            CorrelationId  = auditEvent.CorrelationId,
            Timestamp      = auditEvent.Timestamp,
            AdditionalData = auditEvent.AdditionalData,
            PartitionKey   = auditEvent.UserId ?? "anonymous",
            // Retention: 1 year in seconds (GDPR)
            Ttl            = (int)TimeSpan.FromDays(365).TotalSeconds
        };

        await _container.CreateItemAsync(
            doc, new PartitionKey(doc.PartitionKey));
    }
}

Audit logs are not modified or deleted manually. Append-only is an essential property for audit trail integrity. If you need to anonymize data for GDPR reasons, overwrite the fields UserId, UserName, UserEmail with a hash or with “[anonymized]” — but keep the event.


7. Security Events — automatic attack detection

Beyond recording events, you want to detect patterns indicating an ongoing attack. Some common scenarios:

7.1 Brute-force detection

public class SecurityEventDetector(
    IAuditLogger auditLogger,
    IDistributedCache cache)
{
    // Detect brute-force: X failed logins from the same IP within Y minutes
    public async Task IsLoginBruteForceAsync(
        string ipAddress,
        int maxFailures = 10,
        int windowMinutes = 15)
    {
        var key     = $"login-failures:{ipAddress}";
        var cached  = await cache.GetStringAsync(key);
        var count   = cached is null ? 0 : int.Parse(cached);

        if (count >= maxFailures)
        {
            await auditLogger.LogAsync(
                eventType:    "security.bruteforce.detected",
                action:       "BruteForceDetected",
                succeeded:    false,
                failureReason: $"{count} login failures in {windowMinutes} minutes",
                additionalData: new() { ["ip"] = ipAddress, ["count"] = count });

            return true;
        }

        // Increment the counter
        await cache.SetStringAsync(
            key, (count + 1).ToString(),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromMinutes(windowMinutes)
            });

        return false;
    }
}

7.2 Unusual access — new hours or locations

// Log and alert when a user logs in
// from a new country or at an unusual hour
public async Task LogLoginWithContextAsync(
    string userId,
    string ipAddress,
    DateTime loginTime)
{
    var isUnusualHour = loginTime.Hour < 6 || loginTime.Hour > 22;
    var severity      = isUnusualHour ? "Warning" : "Info";

    await auditLogger.LogAsync(new AuditEvent
    {
        EventType  = isUnusualHour
            ? "auth.login.unusual-time"
            : "auth.login.success",
        Action     = "Login",
        Severity   = severity,
        UserId     = userId,
        IpAddress  = ipAddress,
        Succeeded  = true,
        AdditionalData = new()
        {
            ["hour"]         = loginTime.Hour,
            ["isUnusualTime"] = isUnusualHour
        }
    });
}

8. Alerting in Azure Monitor and Application Insights

Logs without alerting are like an alarm system without sound. You configure automatic alerts in Azure Monitor based on Kusto queries (KQL):

8.1 Alert for brute-force

// KQL Query for Azure Monitor Alert
// Alert if more than 20 failed logins within 5 minutes from the same IP
traces
| where message startswith "[AUDIT]"
| where message contains "auth.login.failed"
| extend ip = tostring(customDimensions.IpAddress)
| summarize failureCount = count() by ip, bin(timestamp, 5m)
| where failureCount > 20
| project timestamp, ip, failureCount

8.2 Alert for unusual administrative access

// Alert if a non-admin accesses admin endpoints
traces
| where message startswith "[AUDIT]"
| where customDimensions.EventType startswith "admin."
| where customDimensions.Succeeded == "False"
| where customDimensions.FailureReason contains "403"
| project timestamp,
          userId = customDimensions.UserId,
          path   = customDimensions.RequestPath,
          ip     = customDimensions.IpAddress
| order by timestamp desc

8.3 Alert configuration in Azure portal

  1. Azure Monitor → Alerts → Create alert rule
  2. Scope: Log Analytics Workspace or Application Insights
  3. Condition: Custom log search (KQL above)
  4. Threshold: Greater than 0 results
  5. Action Group: Email / SMS / Teams webhook / PagerDuty
  6. Frequency: Every 5 minutes, Lookback: 15 minutes

9. GDPR and audit log retention

Audit logs contain personal data (userId, email, IP) and are subject to GDPR. Some practical rules:

Retention

  • Security audit logs: minimum 1 year (recommended 3 years for forensic investigations)
  • Operational logs: 30-90 days
  • Logs of access to medical/financial data: according to specific regulations (e.g., 10 years for tax data in Romania)

Right to deletion — how to apply it without compromising audit trail

// Upon user request for data deletion (GDPR Art. 17),
// anonymize personal data in audit log but keep the event
public async Task AnonymizeUserAuditDataAsync(string userId)
{
    var anonymizedId = $"[deleted-{Guid.NewGuid():N}]";

    // Patch all documents in Cosmos DB with the given userId
    var query = new QueryDefinition(
        "SELECT * FROM c WHERE c.userId = @userId")
        .WithParameter("@userId", userId);

    var iterator = _container.GetItemQueryIterator<AuditEventDocument>(query);

    while (iterator.HasMoreResults)
    {
        var page = await iterator.ReadNextAsync();
        foreach (var doc in page)
        {
            var patches = new[]
            {
                PatchOperation.Set("/UserId",    anonymizedId),
                PatchOperation.Set("/UserName",  "[deleted]"),
                PatchOperation.Set("/UserEmail", "[deleted]")
                // IpAddress and UserAgent can remain — they do not identify the person alone
            };

            await _container.PatchItemAsync<AuditEventDocument>(
                doc.Id, new PartitionKey(doc.PartitionKey), patches);
        }
    }
}

10. Audit logging testing

[TestFixture]
public class AuditLoggerTests
{
    private IAuditLogger _auditLogger = default!;
    private ILogger<AuditLogger> _mockLogger = default!;

    [SetUp]
    public void Setup()
    {
        _mockLogger  = Substitute.For<ILogger<AuditLogger>>();
        var accessor = Substitute.For<IHttpContextAccessor>();
        _auditLogger = new AuditLogger(accessor, _mockLogger);
    }

    [Test]
    public async Task LogAsync_FailedLogin_LogsWarningWithDetails()
    {
        await _auditLogger.LogAsync(
            eventType:     "auth.login.failed",
            action:        "Login",
            succeeded:     false,
            failureReason: "Invalid credentials");

        _mockLogger.Received(1).Log(
            LogLevel.Information,
            Arg.Any<EventId>(),
            Arg.Is<object>(o => o.ToString()!.Contains("auth.login.failed")),
            null,
            Arg.Any<Func<object, Exception?, string>>());
    }

    [Test]
    public async Task LogAsync_NeverLogs_SensitiveData()
    {
        await _auditLogger.LogAsync(
            eventType: "auth.login.failed",
            action:    "Login",
            succeeded: false,
            additionalData: new()
            {
                // Simulate a bug where password might be logged accidentally
                ["email"]    = "user@test.com",
                ["password"] = "SHOULD-NOT-APPEAR"
            });

        // Verify that password does not appear in any log message
        _mockLogger.ReceivedCalls()
            .Select(c => c.GetArguments()[2]?.ToString() ?? "")
            .ToList()
            .ForEach(msg =>
                Assert.That(msg,
                    Does.Not.Contain("SHOULD-NOT-APPEAR")));
    }
}

11. Production checklist

  • ✅ Audit logs separate from operational logs — different retention
  • ✅ Consistent AuditEvent structure with hierarchical EventType
  • ✅ Zero sensitive data in logs: no passwords, tokens, cards
  • ✅ All authentication events logged (success + failure)
  • ✅ All administrative operations logged
  • CorrelationId included for correlating events in a request
  • ✅ Real IP from X-Forwarded-For when app is behind a proxy
  • ✅ Audit logs stored persistently (Cosmos DB, Azure Table Storage) with configured TTL
  • ✅ Alerts in Azure Monitor for brute-force and unauthorized admin access
  • ✅ GDPR anonymization procedure implemented and tested
  • ✅ Audit logs are append-only — no delete or modify operations
  • ✅ Tests verifying sensitive data does not appear in logs

Conclusion — end of the series

Audit logging is the last layer in a complete security strategy — and the first tool you use when something goes wrong. Without it, a security incident is a mystery. With it, it is an investigation with concrete data: who, what, when, where.

This is also the conclusion of the 15-article series about security in ASP.NET Core. We covered the full path: from principles (Security by Design), through authentication and authorization (JWT, OAuth2, certificates, IdentityServer, Keycloak), to protection against attacks (CSRF, XSS, Injection, Rate Limiting) and finally visibility (Audit Logging).

Security is not a feature added at the end — it is a property of the entire system, built layer by layer, from day one of development.