Audit Logging With EF Core Interceptors

Audit Logging With EF Core Interceptors

7 min read··

clean-architecturedotnetef-core

Audit logging in EF Core records who changed which entity, when, and what the values were before and after the change. The practical implementation is a SaveChangesInterceptor that inspects the ChangeTracker before saving, builds an audit entry for every added, modified, and deleted entity, and writes those rows in the same transaction as the change itself.

An update tells you what the database looks like now. It does not tell you who changed it, what the previous value was, or which request made the change. EF Core interceptors can capture that history at the persistence boundary without spreading audit code through every use case.

Why Audit Logging?

Audit logging answers the question "who changed this and when?" Regulatory compliance often requires it, and it makes debugging production data issues far easier.

The challenge is implementing it without scattering audit code across every handler. If every command handler has to remember to write an audit record, someone will forget, and you'll discover the gap during an incident review.

EF Core interceptors give us a single place to capture changes. Every write goes through SaveChanges, so an interceptor sees everything.

Audit interceptor flow: when SaveChangesAsync is called, the interceptor reads the ChangeTracker entries, builds an audit entry per changed IAuditable entity, adds the audit rows to the same DbContext, and everything commits in one transaction.

The Audit Log Entity

public class AuditLogEntry
{
    public Guid Id { get; set; }
    public string EntityName { get; set; }
    public string EntityId { get; set; }
    public string Action { get; set; } // Added, Modified, Deleted
    public string? UserId { get; set; }
    public DateTime Timestamp { get; set; }
    public string? OldValues { get; set; }
    public string? NewValues { get; set; }
    public string? AffectedColumns { get; set; }
}

The IAuditable Marker Interface

Tag entities you want to audit:

public interface IAuditable { }

public class Order : IAuditable
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }
    public decimal TotalAmount { get; set; }
    public string CustomerId { get; set; }
}

The SaveChanges Interceptor

public sealed class AuditLoggingInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public AuditLoggingInterceptor(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken ct = default)
    {
        var context = eventData.Context;

        if (context is null)
        {
            return base.SavingChangesAsync(eventData, result, ct);
        }

        var auditEntries = CreateAuditEntries(context);

        context.Set<AuditLogEntry>().AddRange(auditEntries);

        return base.SavingChangesAsync(eventData, result, ct);
    }

    private List<AuditLogEntry> CreateAuditEntries(DbContext context)
    {
        var entries = new List<AuditLogEntry>();

        context.ChangeTracker.DetectChanges();

        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State is EntityState.Detached or EntityState.Unchanged)
            {
                continue;
            }

            var auditEntry = new AuditLogEntry
            {
                Id = Guid.NewGuid(),
                EntityName = entry.Entity.GetType().Name,
                EntityId = GetPrimaryKey(entry),
                UserId = _currentUser.UserId,
                Timestamp = DateTime.UtcNow,
                Action = entry.State.ToString()
            };

            switch (entry.State)
            {
                case EntityState.Added:
                    auditEntry.NewValues = SerializeProperties(
                        entry.Properties);
                    break;

                case EntityState.Modified:
                    auditEntry.OldValues = SerializeOldValues(entry);
                    auditEntry.NewValues = SerializeNewValues(entry);
                    auditEntry.AffectedColumns = GetModifiedColumns(entry);
                    break;

                case EntityState.Deleted:
                    auditEntry.OldValues = SerializeProperties(
                        entry.Properties);
                    break;
            }

            entries.Add(auditEntry);
        }

        return entries;
    }
}

ICurrentUserService is a small abstraction that exposes the authenticated user's id from the current request.

This overrides only the async path. If any code path calls the synchronous SaveChanges, override SavingChanges as well and reuse the same CreateAuditEntries logic.

Helper Methods

private static string GetPrimaryKey(EntityEntry entry)
{
    // Handles composite keys by joining all key parts
    var keyParts = entry.Properties
        .Where(p => p.Metadata.IsPrimaryKey())
        .Select(p => p.CurrentValue?.ToString() ?? "null");

    return string.Join(",", keyParts);
}

private static string SerializeProperties(
    IEnumerable<PropertyEntry> properties)
{
    var dict = properties.ToDictionary(
        p => p.Metadata.Name,
        p => p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}

private static string SerializeOldValues(EntityEntry entry)
{
    var dict = entry.Properties
        .Where(p => p.IsModified)
        .ToDictionary(
            p => p.Metadata.Name,
            p => p.OriginalValue);

    return JsonSerializer.Serialize(dict);
}

private static string SerializeNewValues(EntityEntry entry)
{
    var dict = entry.Properties
        .Where(p => p.IsModified)
        .ToDictionary(
            p => p.Metadata.Name,
            p => p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}

private static string GetModifiedColumns(EntityEntry entry)
{
    var columns = entry.Properties
        .Where(p => p.IsModified)
        .Select(p => p.Metadata.Name);

    return string.Join(",", columns);
}

Registration

builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
    options.UseNpgsql(connectionString);
    options.AddInterceptors(
        sp.GetRequiredService<AuditLoggingInterceptor>());
});

builder.Services.AddScoped<AuditLoggingInterceptor>();

The DbContext Configuration

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<AuditLogEntry> AuditLogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<AuditLogEntry>(builder =>
        {
            builder.ToTable("AuditLogs");
            builder.HasKey(x => x.Id);
            builder.HasIndex(x => x.EntityName);
            builder.HasIndex(x => x.Timestamp);
            builder.HasIndex(x => new { x.EntityName, x.EntityId });
        });
    }
}

Querying the Audit Log

public sealed class GetAuditHistoryHandler
    : IRequestHandler<GetAuditHistoryQuery, List<AuditLogEntry>>
{
    private readonly ApplicationDbContext _db;

    public GetAuditHistoryHandler(ApplicationDbContext db)
    {
        _db = db;
    }

    public async Task<List<AuditLogEntry>> Handle(
        GetAuditHistoryQuery query, CancellationToken ct)
    {
        return await _db.AuditLogs
            .Where(a => a.EntityName == query.EntityName &&
                        a.EntityId == query.EntityId)
            .OrderByDescending(a => a.Timestamp)
            .ToListAsync(ct);
    }
}

Don't Log Sensitive Data

The audit log serializes every property by default. That includes password hashes, API keys, and personal data.

Audit tables are a classic PII blind spot: teams carefully encrypt the Users table, then store every historical value of it in plain JSON next door. If you're subject to GDPR, "delete this user's data" now includes the audit log too.

The fix is a deny list (or an attribute) applied before serialization:

private static readonly HashSet<string> ExcludedProperties =
    ["PasswordHash", "SecurityStamp", "RefreshToken"];

private static string SerializeProperties(
    IEnumerable<PropertyEntry> properties)
{
    var dict = properties
        .Where(p => !ExcludedProperties.Contains(p.Metadata.Name))
        .ToDictionary(
            p => p.Metadata.Name,
            p => p.CurrentValue);

    return JsonSerializer.Serialize(dict);
}

Decide what's excluded when you build the feature, not after your first data audit.

Performance and Retention

Practical production considerations:

  • Every audited change is an extra insert. For typical CRUD workloads, this is noise. For hot write paths (thousands of writes per second), audit selectively - that's exactly what the IAuditable marker is for.
  • The audit table grows forever unless you do something about it. Add a retention job that archives or deletes entries older than your compliance window.
  • Index intentionally. The indexes in the DbContext configuration above support "history of this entity" queries. Skip indexes you don't query on; they slow down every insert.
  • Don't query audit JSON in hot paths. If you need to report on audit data, project it into a proper reporting table instead of parsing JSON columns at query time.

Soft Deletes + Audit Log

Combine audit logging with soft deletes:

public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken ct = default)
{
    var context = eventData.Context;

    if (context is null)
    {
        return base.SavingChangesAsync(eventData, result, ct);
    }

    // Handle soft deletes
    foreach (var entry in context.ChangeTracker
        .Entries<ISoftDeletable>()
        .Where(e => e.State == EntityState.Deleted))
    {
        entry.State = EntityState.Modified;
        entry.Entity.IsDeleted = true;
        entry.Entity.DeletedAt = DateTime.UtcNow;
    }

    // Create audit entries (includes soft delete as "Modified")
    var auditEntries = CreateAuditEntries(context);
    context.Set<AuditLogEntry>().AddRange(auditEntries);

    return base.SavingChangesAsync(eventData, result, ct);
}

Alternative: Temporal Tables

SQL Server temporal tables provide database-level change tracking. They're a great fit when you need point-in-time queries ("what did this row look like last Tuesday?") with zero application code.

But they only track what changed, not who changed it, and they capture the full row rather than just the modified columns. They also don't see changes as business actions - just row versions.

For user-level audit trails, use the interceptor approach. For time-travel queries or protection against direct SQL modifications, use temporal tables. Some systems justify both.

One more caveat for the interceptor approach: it only sees changes that go through the change tracker. Bulk operations like ExecuteUpdateAsync and raw SQL bypass SaveChanges entirely, so they bypass your audit log too. If you use bulk updates, write their audit entries explicitly.

Summary

SaveChangesInterceptor is a useful audit boundary because it sees tracked inserts, updates, and deletes in one place. Filter the audited entities and properties, include user context, and keep sensitive values out of the payload. Bulk APIs and raw SQL bypass this path, so cover those writes separately or make the limitation explicit.

Frequently Asked Questions

How do I implement audit logging in EF Core?

Create a SaveChangesInterceptor that inspects the ChangeTracker before saving, builds audit entries for added, modified, and deleted entities, and adds them to the same transaction. This captures every change without touching business logic.

What is the difference between audit logging and SQL Server temporal tables?

Temporal tables track row versions at the database level but do not know which application user made the change. Interceptor-based audit logging can record the user, the action, and only the changed columns.

Does audit logging slow down SaveChanges?

It adds an insert per audited change plus serialization and index maintenance. Measure that cost on write-heavy paths, audit only the entities that need it, and keep indexes on the audit table lean.

Should I store old and new values as JSON?

JSON columns are the most practical option because audit data is schemaless by nature. On SQL Server 2025+ and PostgreSQL you can use native JSON column types and even query into them.

How do I avoid logging sensitive data in audit logs?

Exclude sensitive properties explicitly, for example by checking property names against a deny list or marking them with an attribute before serializing. Audit tables are a common blind spot for PII and password hashes.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.