TL;DR
  • Use custom middleware to log HTTP request and response bodies in ASP.NET Core for better debugging and diagnostics.
  • Implement stream rewinding to read request bodies without breaking downstream middleware.
  • Always redact sensitive data and use selective logging to avoid performance and security issues.
  • Handle large bodies by truncating logs and excluding static or health check endpoints.
  • Consider built-in HttpLogging for simple scenarios, but use custom middleware for full control and compliance.

We’ve all been there, stuck debugging an API issue for hours, wishing we could just see what’s actually coming in and going out of our application. That’s exactly what request and response body logging solves.

I’m going to walk you through how to build a practical logging solution for HTTP traffic in ASP.NET Core using custom middleware.

Why Log HTTP Request and Response Bodies?

Have you ever spent hours banging your head against the wall trying to debug an API issue? I certainly have. There’s that moment of pure frustration when you know something’s wrong with the data flowing through your system, but you can’t quite put your finger on it. That’s exactly when request and response body logging becomes your best friend.

I remember one particularly painful debugging session where a client swore up and down they were sending the correct JSON payload. After adding proper request logging, we discovered they were actually sending an array where we expected an object. Five minutes to implement logging saved us days of back-and-forth troubleshooting.

When you’re integrating with third-party services, this visibility becomes even more valuable. Ever tried to figure out why that payment processor keeps rejecting your perfectly valid requests? With request/response logging, you can see precisely what’s going back and forth, often revealing subtle formatting issues or missing headers that documentation never mentioned.

For many of my clients in healthcare and finance, logging isn’t just helpful, it’s a compliance requirement. Being able to prove exactly who did what and when provides the audit trail that keeps auditors happy and systems secure. These logs have saved my clients more than once when security questions arose about specific transactions.

What I love most about implementing custom logging is how it helps spot patterns over time.

Users might report intermittent issues that are nearly impossible to reproduce, but when you review the logs, you might notice that specific input structures or unusual edge cases consistently trigger problems. These insights are gold for improving system stability.

While ASP.NET Core does include built-in HttpLogging middleware that handles some basic scenarios, building your own solution gives you complete control.

You can decide exactly what gets captured, how sensitive data is handled, and where those logs end up. This level of customization is critical when you’re dealing with real-world applications where one size definitely doesn’t fit all.

To understand request throttling, you can refer to my post on Request Throttling Middleware with MemoryCache and IP Limits.

The Challenge with Logging Bodies in ASP.NET Core

Logging bodies in ASP.NET Core comes with a couple of tricky issues:

  1. Request bodies can only be read once, Once you read the request body stream, it’s done, other middleware down the pipeline won’t be able to read it again.

  2. Response bodies don’t exist yet when your middleware runs, You need a special approach to capture what gets written to the response after your code runs.

Let’s solve both problems with a practical example that you can use in your projects.

Diagram showing the flow of request and response through the logging middleware

HTTP Request and Response Flow Through Logging Middleware

Creating the Request/Response Logging Middleware

Let’s start by creating a middleware that can log both request and response bodies:

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

public class RequestResponseLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
    
    public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        // First, store the original body stream
        var originalRequestBody = context.Request.Body;
        var originalResponseBody = context.Response.Body;

        try
        {
            // Read and log the request body
            string requestBody = await ReadRequestBodyAsync(context);
            LogRequest(context, requestBody);
            
            // Create a new memory stream for the response
            using var responseBodyStream = new MemoryStream();
            context.Response.Body = responseBodyStream;
            
            // Continue the pipeline
            await _next(context);
            
            // Read and log the response body
            string responseBody = await ReadResponseBodyAsync(context.Response);
            LogResponse(context, responseBody);
            
            // Copy the response to the original stream and restore it
            await responseBodyStream.CopyToAsync(originalResponseBody);
        }
        finally
        {
            // Always restore the original streams
            context.Request.Body = originalRequestBody;
            context.Response.Body = originalResponseBody;
        }
    }
    
    private async Task<string> ReadRequestBodyAsync(HttpContext context)
    {
        // We need to read the request body and then rewind it
        context.Request.EnableBuffering();
        
        using var streamReader = new StreamReader(
            context.Request.Body,
            encoding: Encoding.UTF8,
            detectEncodingFromByteOrderMarks: false,
            leaveOpen: true);
        
        var requestBody = await streamReader.ReadToEndAsync();
        
        // IMPORTANT: Reset the position to 0 to allow other middleware to read it
        context.Request.Body.Position = 0;
        
        return requestBody;
    }
    
    private async Task<string> ReadResponseBodyAsync(HttpResponse response)
    {
        response.Body.Seek(0, SeekOrigin.Begin);
        var responseBody = await new StreamReader(response.Body).ReadToEndAsync();
        response.Body.Seek(0, SeekOrigin.Begin);
        
        return responseBody;
    }
      private void LogRequest(HttpContext context, string requestBody)
    {
        _logger.LogInformation(
            "HTTP {Method} {Path} received at {Time}\n" +
            "Request Headers: {Headers}\n" +
            "Request Body: {Body}",
            context.Request.Method,
            context.Request.Path,
            DateTime.UtcNow,
            FormatHeaders(context.Request.Headers),
            requestBody);
    }
    
    private void LogResponse(HttpContext context, string responseBody)
    {
        _logger.LogInformation(
            "HTTP {StatusCode} returned for {Method} {Path}\n" +
            "Response Headers: {Headers}\n" +
            "Response Body: {Body}",
            context.Response.StatusCode,
            context.Request.Method,
            context.Request.Path,
            FormatHeaders(context.Response.Headers),
            responseBody);
    }
    
    private string FormatHeaders(IHeaderDictionary headers)
    {
        var sb = new StringBuilder();
        foreach (var (key, value) in headers)
        {
            sb.AppendLine($"{key}: {string.Join(", ", value)}");
        }
        return sb.ToString();
    }
}

Let’s understand what’s happening here:

  1. We’re using EnableBuffering() to make the request body readable multiple times
  2. We’re replacing the response body with a memory stream that we control
  3. After the next middleware completes, we read what was written to our memory stream
  4. Finally, we copy our captured response back to the original stream

Creating an Extension Method for Clean Registration

Let’s add an extension method to make it easy to add our middleware to the pipeline:

using Microsoft.AspNetCore.Builder;

public static class RequestResponseLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestResponseLoggingMiddleware>();
    }
}

Adding the Middleware to the Pipeline

In your Program.cs or Startup.cs, add the middleware to your pipeline:

// Program.cs in ASP.NET Core 6+
var builder = WebApplication.CreateBuilder(args);

// Add services...

var app = builder.Build();

// Add the logging middleware early in the pipeline
app.UseRequestResponseLogging(); 

// Other middleware...
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Making the Logging Middleware Smarter

The basic implementation works, but let’s enhance it to handle real-world scenarios better:

1. Adding Selective Logging

You probably don’t want to log every request and response, especially for static files or health checks:

public class RequestResponseLoggingOptions
{
    public bool LogRequests { get; set; } = true;
    public bool LogResponses { get; set; } = true;
    public List<string> ExcludedPaths { get; set; } = new List<string>();
    public int? MaxBodyLogSize { get; set; } = 4096; // Limit logging to 4KB by default
    
    // Add a method to check if a path should be logged
    public bool ShouldLog(PathString path)
    {
        return !ExcludedPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
    }
}

// Updated middleware constructor
public RequestResponseLoggingMiddleware(
    RequestDelegate next, 
    ILogger<RequestResponseLoggingMiddleware> logger,
    RequestResponseLoggingOptions options)
{
    _next = next;
    _logger = logger;
    _options = options;
}

// Use in InvokeAsync
public async Task InvokeAsync(HttpContext context)
{
    // Skip logging if path is excluded
    if (!_options.ShouldLog(context.Request.Path))
    {
        await _next(context);
        return;
    }
    
    // Rest of the implementation...
}

2. Handling Different Content Types

Not all requests and responses are JSON. Let’s make our middleware smarter about what content it should log:

private bool CanLogBody(string contentType)
{
    if (string.IsNullOrEmpty(contentType))
        return false;
        
    return contentType.Contains("json") || 
           contentType.Contains("xml") ||
           contentType.Contains("text/") ||
           contentType.Contains("form-urlencoded");
}

public async Task InvokeAsync(HttpContext context)
{
    var originalRequestBody = context.Request.Body;
    var originalResponseBody = context.Response.Body;

    try
    {
        // Only log request body if appropriate content type
        string requestBody = "";
        if (_options.LogRequests && CanLogBody(context.Request.ContentType))
        {
            requestBody = await ReadRequestBodyAsync(context);
            LogRequest(context, requestBody);
        }
        else if (_options.LogRequests)
        {
            LogRequestMetadataOnly(context);
        }
        
        // Handle response...
    }
    finally
    {
        // Always restore streams...
    }
}

3. Handling Large Bodies

For large request or response bodies, you might want to truncate them:

private string TruncateIfNeeded(string content)
{
    if (_options.MaxBodyLogSize.HasValue && content.Length > _options.MaxBodyLogSize.Value)
    {
        return content.Substring(0, _options.MaxBodyLogSize.Value) + 
               $"... [Truncated after {_options.MaxBodyLogSize.Value} characters]";
    }
    
    return content;
}

A Complete Real-World Implementation

Here’s a more complete implementation that brings together all these concepts:

public class RequestResponseLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
    private readonly RequestResponseLoggingOptions _options;
    
    public RequestResponseLoggingMiddleware(
        RequestDelegate next, 
        ILogger<RequestResponseLoggingMiddleware> logger,
        RequestResponseLoggingOptions options = null)
    {
        _next = next;
        _logger = logger;
        _options = options ?? new RequestResponseLoggingOptions();
    }
    
    public async Task InvokeAsync(HttpContext context)
    {
        // Skip excluded paths
        if (!_options.ShouldLog(context.Request.Path))
        {
            await _next(context);
            return;
        }
        
        var originalRequestBody = context.Request.Body;
        var originalResponseBody = context.Response.Body;
        
        try
        {
            // Handle request
            if (_options.LogRequests)
            {
                if (CanLogBody(context.Request.ContentType))
                {
                    string requestBody = await ReadRequestBodyAsync(context);
                    LogRequest(context, TruncateIfNeeded(requestBody));
                }
                else
                {
                    LogRequestMetadataOnly(context);
                }
            }
            
            // Handle response
            if (_options.LogResponses)
            {
                using var responseBodyStream = new MemoryStream();
                context.Response.Body = responseBodyStream;
                
                await _next(context);
                
                if (CanLogBody(context.Response.ContentType))
                {
                    string responseBody = await ReadResponseBodyAsync(context.Response);
                    LogResponse(context, TruncateIfNeeded(responseBody));
                }
                else
                {
                    LogResponseMetadataOnly(context);
                }
                
                await responseBodyStream.CopyToAsync(originalResponseBody);
            }
            else
            {
                await _next(context);
            }
        }
        finally
        {
            context.Request.Body = originalRequestBody;
            context.Response.Body = originalResponseBody;
        }
    }
    
    // Helper methods as shown earlier...
    
    private void LogRequestMetadataOnly(HttpContext context)
    {
        _logger.LogInformation(
            "HTTP {Method} {Path} received at {Time} [Body logging skipped - unsupported content type]",
            context.Request.Method,
            context.Request.Path,
            DateTime.UtcNow);
    }
    
    private void LogResponseMetadataOnly(HttpContext context)
    {
        _logger.LogInformation(
            "HTTP {StatusCode} returned for {Method} {Path} [Body logging skipped - unsupported content type]",
            context.Response.StatusCode,
            context.Request.Method,
            context.Request.Path);
    }
}

Creating a More Flexible Registration

Let’s update our extension method to accept options:

public static class RequestResponseLoggingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestResponseLogging(
        this IApplicationBuilder builder, 
        Action<RequestResponseLoggingOptions> configureOptions = null)
    {
        var options = new RequestResponseLoggingOptions();
        configureOptions?.Invoke(options);
        
        return builder.UseMiddleware<RequestResponseLoggingMiddleware>(options);
    }
}

This allows us to use it like this:

app.UseRequestResponseLogging(options => {
    options.ExcludedPaths.Add("/healthcheck");
    options.ExcludedPaths.Add("/swagger");
    options.MaxBodyLogSize = 8192; // 8KB
});

Example: Real-World Application

Let’s see how this middleware might be used in a real API controller:

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;
    
    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }
    
    [HttpPost]
    public async Task<ActionResult<OrderResponse>> CreateOrder(OrderRequest request)
    {
        // With our logging middleware, we'll see:
        // 1. The full incoming OrderRequest JSON in the logs
        // 2. The full outgoing OrderResponse JSON in the logs
        var order = await _orderService.CreateOrderAsync(request);
        return Ok(new OrderResponse { OrderId = order.Id, Status = order.Status });
    }
}

Handling Sensitive Data

Let’s face it, you don’t want passwords and credit cards showing up in your logs. Here’s a simple way to redact sensitive info:

private string RedactSensitiveInfo(string content, string contentType)
{
    // Only try to redact if it looks like JSON
    if (contentType?.Contains("json") == true && !string.IsNullOrEmpty(content))
    {
        try
        {
            // Simple regex replacements for common sensitive fields
            content = Regex.Replace(content, "\"password\":\"[^\"]*\"", "\"password\":\"[REDACTED]\"", RegexOptions.IgnoreCase);
            content = Regex.Replace(content, "\"creditCard\":\"[^\"]*\"", "\"creditCard\":\"[REDACTED]\"", RegexOptions.IgnoreCase);
            content = Regex.Replace(content, "\"ssn\":\"[^\"]*\"", "\"ssn\":\"[REDACTED]\"", RegexOptions.IgnoreCase);
            
            // For a more robust solution, you'd want to use a JSON parser
            // and systematically identify and redact sensitive fields
        }
        catch
        {
            // If redaction fails, fall back to safe behavior
            return "[Content contained sensitive data - redaction failed]";
        }
    }
    
    return content;
}

Performance Considerations

Logging request and response bodies can impact performance. Here are some things to keep in mind:

ConsiderationImpactMitigation
Memory usageEach request/response body is temporarily stored in memoryUse MaxBodyLogSize to limit size
I/O operationsExtra disk writes for loggingUse asynchronous logging
CPU overheadParsing and redacting dataImplement selective logging by path
Response timeSlight increase due to stream manipulationProfile and optimize for your use case

Using the Built-in HttpLogging (Alternative Approach)

ASP.NET Core also includes built-in HTTP logging middleware that can handle some of these scenarios. If your needs are simpler, you might consider using it:

// In Program.cs
builder.Services.AddHttpLogging(logging =>
{
    logging.LoggingFields = HttpLoggingFields.All;
    logging.RequestHeaders.Add("Authorization");
    logging.RequestHeaders.Add("X-Real-IP");
    logging.ResponseHeaders.Add("X-Response-Time");
    logging.MediaTypeOptions.AddText("application/json");
    logging.RequestBodyLogLimit = 4096;
    logging.ResponseBodyLogLimit = 4096;
});

// In middleware pipeline
app.UseHttpLogging();

The built-in solution is more limited but might be sufficient for simpler scenarios.

Where to Log: Console vs File vs Service

Once you have request and response bodies, you need to decide where to log them. Here are some options:

  1. Console logging - Good for development, easy to set up
  2. File logging - Good for simple production systems
  3. Structured logging - Using something like Serilog or NLog
  4. Centralized logging - Send logs to Elasticsearch, Splunk, or similar

Here’s how to set up structured logging with Serilog:

// In Program.cs
builder.Host.UseSerilog((ctx, services, lc) => lc
    .ReadFrom.Configuration(ctx.Configuration)
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .WriteTo.File("logs/requests-.txt", rollingInterval: RollingInterval.Day));

Conclusion

Adding request and response body logging to your ASP.NET Core app gives you x-ray vision when debugging API problems. You can see exactly what’s going in and out, something that’s worth its weight in gold when you’re stuck on a tough issue.

Just remember these key takeaways:

  1. Use EnableBuffering() so request bodies can be read multiple times
  2. Swap the response body with a memory stream to capture what gets written
  3. Always redact sensitive data before it hits your logs
  4. Be smart about what you log to avoid performance issues
  5. Consider the built-in HttpLogging for simpler cases

Logging responses is especially powerful when combined with a clean response model, check out my guide on Clean ASP.NET Core API Response Patterns to design consistent envelopes for your JSON outputs

Implement this in your projects, and you’ll thank yourself next time you’re debugging a tricky API integration. Good luck and happy logging!

Frequently Asked Questions

How can I log request and response bodies in ASP.NET Core?

You can log request and response bodies in ASP.NET Core by implementing custom middleware that uses stream rewinding techniques. This involves creating a middleware that reads the request body, stores it for logging, and then resets the stream position so other middleware can read it again. For responses, you need to wrap the original response stream with a custom stream that can capture the data being written.

Why would I need to log HTTP request and response bodies?

Logging HTTP request and response bodies is essential for debugging complex API issues, auditing sensitive operations, monitoring system behavior, troubleshooting third-party integrations, and meeting compliance requirements in certain regulated industries.

Is there a performance impact when logging request and response bodies?

Yes, there can be a performance impact when logging request and response bodies due to additional memory usage, I/O operations, and processing overhead. You can minimize this by implementing selective logging based on endpoints, sampling, or log levels.

Does ASP.NET Core have built-in request and response body logging?

ASP.NET Core includes HttpLogging middleware that can log headers, request bodies, and other HTTP data, but full request and response body logging often requires custom middleware implementation for more control over what and how data is logged.

How can I avoid logging sensitive data from request and response bodies?

You can avoid logging sensitive data by implementing redaction logic in your middleware, using path-based filters to exclude certain endpoints, creating allowlists of fields to log, and implementing data masking for sensitive fields like passwords or personal information.

Can I log request bodies without affecting downstream middleware?

Yes, you can log request bodies without affecting downstream middleware by using the stream rewinding technique. After reading the request body for logging, you reset the stream position to its beginning using stream.Position = 0, allowing downstream middleware to read it normally.

How do I handle large request or response bodies in my logging middleware?

For large request or response bodies, implement size limits, log only summaries or truncated content, use sampling to log only a percentage of requests, or implement conditional logging based on content type or request path.

How can I log multipart form data and file uploads?

To log multipart form data and file uploads, you should carefully implement your logging middleware to handle the specific content types, potentially logging metadata about files (name, size) rather than entire file contents to avoid memory issues.

Where should logging middleware be placed in the middleware pipeline?

Logging middleware should typically be placed early in the pipeline to capture incoming requests but after any critical middleware like authentication. For response logging, it needs to wrap subsequent middleware to capture their output.
See other aspnet-core posts