In modern application development, especially with APIs, knowing what’s happening under the hood isn’t just a “nice-to-have,” it’s a necessity. Audit logging provides a detailed record of every significant action, which is invaluable for security, compliance (like GDPR or HIPAA), and debugging complex issues.

While you could add logging calls in every controller action, that approach is repetitive and error-prone. A much cleaner, more powerful solution is to use ASP.NET Core’s middleware. In this post, we’ll build a piece of custom middleware from scratch to create a robust audit logging system for any API.

What is Middleware, Really?

Think of the ASP.NET Core request pipeline as an assembly line. When an HTTP request comes in, it passes through a series of components, or “middleware,” before it reaches your API controller. Each piece of middleware has a chance to inspect the request, modify it, or even short-circuit it. After the controller generates a response, it travels back down the same line.

This structure makes middleware the perfect place for cross-cutting concerns like authentication, caching, exception handling, and, of course, logging.

Designing Our Audit Logging Middleware

Before writing code, let’s define what information we want to capture for each API call. A good audit log should be comprehensive.

Key Data Points to Log:

  • Request Info: HTTP Method, Path, Query String, Headers, and the Request Body.
  • Response Info: Status Code and the Response Body.
  • User Info: IP Address, User Agent, and Authenticated User ID (if available).
  • Timing: Timestamp of the request and the total processing duration.

The trickiest parts here are reading the request and response bodies. Both are streams, which by default, can only be read once. We’ll need a special technique to read them for our log while still allowing the rest of the application to access them.

Implementing the Audit Logging Middleware

Let’s get our hands dirty and build the middleware.

First, create a new class named AuditLoggingMiddleware.cs.

public class AuditLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<AuditLoggingMiddleware> _logger;

    public AuditLoggingMiddleware(RequestDelegate next, ILogger<AuditLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Implementation will go here
    }
}

This is the standard structure for middleware. We inject RequestDelegate to call the next component in the pipeline and ILogger to write our logs.

The InvokeAsync Method: The Heart of the Middleware

All the magic happens inside the InvokeAsync method. We’ll build it step-by-step.

1. Capture Initial Request Data and Start the Timer

We’ll start by grabbing simple request details and timing the operation with a Stopwatch.

public async Task InvokeAsync(HttpContext context)
{
    var stopwatch = Stopwatch.StartNew();

    var request = context.Request;
    var requestTime = DateTime.UtcNow;

    // This is the key to reading the request body multiple times
    request.EnableBuffering(); 
    
    var requestBody = await ReadRequestBodyAsync(request);

    // After reading, reset the stream position for the next middleware
    request.Body.Position = 0;

    // ... more to come
}

Expert Tip: The request.EnableBuffering() call is non-negotiable. Without it, the request body stream would be consumed after our first read, causing a “stream has already been read” error later in the pipeline (e.g., when model binding occurs in your controller).

2. Read the Request and Response Bodies Safely

Here are the helper methods to read the streams without disrupting the application flow.

For the request body, we read it into a string and then reset the stream’s position.

private async Task<string> ReadRequestBodyAsync(HttpRequest request)
{
    // Ensure the request body can be read multiple times
    request.EnableBuffering();
    
    using var reader = new StreamReader(request.Body, leaveOpen: true);
    var bodyAsString = await reader.ReadToEndAsync();
    
    // Reset the stream position for the next middleware
    request.Body.Position = 0;
    
    return bodyAsString;
}

Handling the response is a bit more involved. We need to temporarily replace the original response stream with a MemoryStream. This lets us capture everything written to the response body. Once the pipeline finishes, we read our MemoryStream, log its content, and then copy it back to the original stream to send to the client.

public async Task InvokeAsync(HttpContext context)
{
    // ... code from step 1 ...

    var originalResponseBodyStream = context.Response.Body;
    await using var responseBodyStream = new MemoryStream();
    context.Response.Body = responseBodyStream;
    
    // Call the next middleware in the pipeline
    await _next(context);

    stopwatch.Stop();

    // Capture response details
    var response = context.Response;
    responseBodyStream.Position = 0;
    var responseBody = await new StreamReader(responseBodyStream).ReadToEndAsync();
    responseBodyStream.Position = 0;

    // IMPORTANT: Copy the captured response back to the original stream
    await responseBodyStream.CopyToAsync(originalResponseBodyStream);

    // Log the complete audit entry
    LogAuditEntry(context, requestTime, stopwatch.Elapsed, requestBody, responseBody);
}

private async Task<string> ReadRequestBodyAsync(HttpRequest request) 
{
    // ... same as before ... 
}

private void LogAuditEntry(HttpContext context, DateTime requestTime, TimeSpan duration, string requestBody, string responseBody)
{
    // ... logging logic ...
}

3. Assemble and Log the Audit Entry

Now we bring it all together. The LogAuditEntry method will format our collected data and write it using ILogger. Using structured logging is highly recommended, as it makes your logs machine-readable and easy to query.

private void LogAuditEntry(HttpContext context, DateTime requestTime, TimeSpan duration, string requestBody, string responseBody)
{
    var request = context.Request;
    var response = context.Response;

    var auditLog = new
    {
        Timestamp = requestTime,
        ProcessingTimeMs = duration.TotalMilliseconds,
        IpAddress = context.Connection.RemoteIpAddress?.ToString(),
        UserName = context.User.Identity?.Name ?? "Anonymous",
        RequestMethod = request.Method,
        RequestPath = request.Path,
        RequestQuery = request.QueryString.ToString(),
        RequestBody = requestBody, // Be careful with sensitive data!
        StatusCode = response.StatusCode,
        ResponseBody = responseBody // Be careful with sensitive data!
    };

    _logger.LogInformation("API Audit Log: {@AuditLog}", auditLog);
}

Security Warning: Logging raw request and response bodies can expose sensitive information like passwords, tokens, or personal data (PII). In a production system, you must implement a mechanism to filter or redact this sensitive data before logging.

Making Registration Easy with an Extension Method

To keep our Program.cs file clean, we’ll create a simple extension method to register the middleware.

Create a new file AuditLoggingMiddlewareExtensions.cs:

public static class AuditLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseAuditLogging(this IApplicationBuilder app)
    {
        return app.UseMiddleware<AuditLoggingMiddleware>();
    }
}

Adding the Middleware to the Pipeline

Finally, we register the middleware in Program.cs. The order is important. You should place it early in the pipeline to capture the request before other middleware modifies it, but after authentication and authorization so that context.User is populated.

// In Program.cs

var builder = WebApplication.CreateBuilder(args);

// ... Add services ...

var app = builder.Build();

// ... Configure other middleware like exception handling, HTTPS redirection ...

app.UseAuthentication();
app.UseAuthorization();

// Add our custom audit logging middleware here
app.UseAuditLogging();

app.MapControllers();

app.Run();

And that’s it! With this middleware in place, every API call will now generate a detailed, structured audit log entry, giving you complete visibility into your application’s activity.

Final Considerations

  • Performance: Logging every single request and response body can impact performance. For high-traffic APIs, consider making logging configurable or only logging bodies for error responses (4xx and 5xx status codes).
  • Storage: For production, logging to the console isn’t enough. Integrate a robust logging provider like Serilog or NLog to send logs to a file, database, or a centralized logging platform like Seq, Datadog, or Azure Monitor.
  • Data Redaction: As mentioned, create a service that can scan log data for patterns (e.g., credit card numbers, passwords) and replace them with [REDACTED] before logging.

This middleware provides a powerful foundation. You can easily extend it to meet specific business or compliance requirements, giving you peace of mind and a clear record of your API’s history.

References