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