Secure Exception Logging in C# Without Data Leaks

Logging exceptions in C# is critical for diagnosing application failures, but default exception handling often inadvertently captures sensitive information such as passwords, API tokens, credit card numbers, and Personally Identifiable Information (PII). This article provides practical, actionable strategies for securing your .NET logging pipelines. By implementing structured data redaction, using modern .NET compliance libraries, and sanitizing stack traces and custom exception types, you can maintain deep diagnostic visibility without violating privacy standards or compliance regulations like GDPR and HIPAA.

The Risks of Default Exception Logging

Default exception logging usually serializes the entire exception tree via ex.ToString(), including:

  • Connection strings embedded in SqlException instances.
  • Bearer tokens or authorization headers found in HttpRequestException data or HTTP client wrappers.
  • Query parameters and raw payloads attached to validation exceptions.
  • Local parameter values visible in specialized stack-trace diagnostics.

Exposing this data in log aggregators exposes your infrastructure to credential harvesting and data breaches.


Strategy 1: Use .NET 8 Redaction Framework

Starting with .NET 8, Microsoft introduced built-in compliance and redaction packages via Microsoft.Extensions.Compliance.Redaction. This allows you to classify data taxonomies and automatically redact sensitive properties before writing to logs.

Installation

dotnet add package Microsoft.Extensions.Compliance.Redaction

Implementation

Define a custom data classification:

using Microsoft.Extensions.Compliance.Classification;

public static class DataTaxonomy
{
    public static string TaxonomyName => "CustomTaxonomy";
    public static DataClassification SensitiveInfo => new(TaxonomyName, "SensitiveInfo");
}

public class SensitiveDataAttribute : DataClassificationAttribute
{
    public SensitiveDataAttribute() : base(DataTaxonomy.SensitiveInfo) { }
}

Configure the redactor in your dependency injection container:

using Microsoft.Extensions.Compliance.Redaction;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRedaction(options =>
{
    options.SetRedactor<StarRedactor>(DataTaxonomy.SensitiveInfo);
});

When handling exceptions, pass sensitive context through the configured IRedactorProvider rather than serializing raw exception context objects.


Strategy 2: Implement a Serilog Destructuring Policy

If you use Serilog, default object destructuring ({@Exception}) serializes all object properties. Use custom IDestructuringPolicy implementations to scrub properties matching sensitive keywords like Password, Token, Secret, or CreditCard.

using System.Reflection;
using Serilog.Core;
using Serilog.Events;

public class SensitiveDataDestructuringPolicy : IDestructuringPolicy
{
    private static readonly HashSet<string> SensitiveKeywords = new(StringComparer.OrdinalIgnoreCase)
    {
        "Password", "Token", "Secret", "Authorization", "ApiKey", "CreditCard"
    };

    public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result)
    {
        if (value is Exception)
        {
            // Do not bypass standard exception handling, allow targeted property inspection
            result = null!;
            return false;
        }

        var type = value.GetType();
        if (type.IsPrimitive || type == typeof(string))
        {
            result = null!;
            return false;
        }

        var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
        var logProperties = new List<LogEventProperty>();

        foreach (var prop in properties)
        {
            var propVal = prop.GetValue(value);
            if (SensitiveKeywords.Contains(prop.Name))
            {
                logProperties.Add(new LogEventProperty(prop.Name, new ScalarValue("***REDACTED***")));
            }
            else
            {
                logProperties.Add(new LogEventProperty(prop.Name, propertyValueFactory.CreatePropertyValue(propVal, destructureObjects: true)));
            }
        }

        result = new StructureValue(logProperties);
        return true;
    }
}

Register the policy during logger configuration:

Log.Logger = new LoggerConfiguration()
    .Destructure.With<SensitiveDataDestructuringPolicy>()
    .WriteTo.Console()
    .CreateLogger();

Strategy 3: Sanitize Standard Exception Messages and Payloads

Third-party exceptions (such as database or HTTP clients) often embed raw query payloads or URLs containing secrets. Strip these values using regular expressions before logging the exception message.

using System.Text.RegularExpressions;

public static class ExceptionSanitizer
{
    private static readonly Regex TokenRegex = new(
        @"(Bearer\s+)[A-Za-z0-9\-\._~\+\/]+=*", 
        RegexOptions.Compiled | RegexOptions.IgnoreCase);

    private static readonly Regex KeyValueSecretRegex = new(
        @"(?i)(password|secret|apikey|token)\s*[:=]\s*['""]?(?<secret>[^'"";\s]+)['""]?", 
        RegexOptions.Compiled);

    public static string SanitizeMessage(string message)
    {
        if (string.IsNullOrWhiteSpace(message))
            return string.Empty;

        var sanitized = TokenRegex.Replace(message, "$1[REDACTED]");
        sanitized = KeyValueSecretRegex.Replace(sanitized, "$1=[REDACTED]");
        
        return sanitized;
    }
}

Strategy 4: Design Secure Custom Exceptions

When building domain-specific exceptions, separate diagnostic contextual metadata from user-entered sensitive data.

public abstract class SecureException : Exception
{
    protected SecureException(string safeMessage) : base(safeMessage) { }

    // Contains safe diagnostic data for logging
    public Dictionary<string, object> DiagnosticContext { get; } = new();

    // Never log this property directly
    public object? SensitiveState { get; init; }
}

public class PaymentProcessingException : SecureException
{
    public PaymentProcessingException(string transactionId, string customerId) 
        : base($"Payment processing failed for Transaction: {transactionId}")
    {
        DiagnosticContext.Add("TransactionId", transactionId);
        DiagnosticContext.Add("CustomerId", customerId);
    }
}

Log only the safe properties explicitly:

try
{
    // Business logic
}
catch (PaymentProcessingException ex)
{
    logger.LogError(ex, "Handled secure payment error. Context: {@DiagnosticContext}", ex.DiagnosticContext);
}
catch (Exception ex)
{
    var safeMessage = ExceptionSanitizer.SanitizeMessage(ex.Message);
    logger.LogError("{ExceptionType}: {SanitizedMessage}\n{StackTrace}", 
        ex.GetType().FullName, 
        safeMessage, 
        ex.StackTrace);
}

Strategy 5: Strip Request Bodies and Headers in ASP.NET Core Middleware

Global exception handlers often record HTTP context details. Avoid dumping request headers, cookies, and raw bodies into error logs unconditionally.

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

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

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            LogSanitizedError(context, ex);
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new { error = "An internal server error occurred." });
        }
    }

    private void LogSanitizedError(HttpContext context, Exception ex)
    {
        // Safe metadata only: method, route template, and status code. Avoid raw QueryString and Body.
        var path = context.Request.Path;
        var method = context.Request.Method;
        var safeMessage = ExceptionSanitizer.SanitizeMessage(ex.Message);

        _logger.LogError(
            ex, 
            "Unhandled exception during {Method} {Path}. Details: {SanitizedMessage}", 
            method, 
            path, 
            safeMessage);
    }
}

Key Rules Checklist

  1. Never log raw Exception.ToString() directly if third-party libraries pass connection strings or auth tokens in their error messages.
  2. Classify data explicitly using Microsoft Redaction or logging destructuring filters.
  3. Redact known identifiers (Bearer tokens, passwords, credit card numbers) using compiled regex passes over messages.
  4. Isolate sensitive fields from custom exception payloads by not exposing them in standard message strings.
  5. Separate internal logs from client responses, returning only an opaque Correlation ID or Trace ID to the API consumer.