Skip to main content
SEMastery
Data Accessintermediate

How to Implement an Audit Trail in ASP.NET Core with EF Core

Build an automatic audit trail in ASP.NET Core with EF Core using a SaveChanges interceptor and the ChangeTracker. Simple examples, diagrams, and best practices for .NET 10.

11 min readUpdated December 11, 2025

A CCTV camera for your database

Think about a small jewellery shop. The owner keeps a CCTV camera running all day. The camera does not stop anyone from entering. It does not change what people do. It simply records everything quietly in the corner. If a ring goes missing, the owner can rewind the tape and see exactly who touched it and at what time.

An audit trail is the CCTV camera for your database. Every time a row is added, changed, or deleted, the audit trail writes a small note: what changed, who did it, and when. Your app keeps working normally. But now you have a recording. If a price suddenly looks wrong, or a customer complains that their address was edited, you can rewind and see the truth.

In this article we will build that camera in ASP.NET Core with EF Core. The best part is that it runs automatically. You write the code once, and from then on every change is recorded without you remembering to do anything.

What we are building

Our plan is simple. We will create a separate table called AuditLogs. Each time you call SaveChanges, a small piece of code will look at everything that is about to change and write one audit row per changed entity.

Figure 1: A normal save goes straight to the database. With an audit trail, a quiet observer records every change first.

The "quiet observer" is an EF Core interceptor. It is the cleanest way to do this. Let us first understand the tool that makes it all possible: the ChangeTracker.

The ChangeTracker: EF Core already knows what changed

Here is something many people do not realise. EF Core is always watching your entities. When you load a row, change a property, add a new object, or call Remove, EF Core remembers the state of each entity. This memory is called the ChangeTracker.

Every tracked entity has a state. These are the states we care about:

Entity stateWhat it meansWhat we record
AddedA brand new row is being insertedThe new values
ModifiedAn existing row's columns changedOld values and new values
DeletedA row is being removedThe values before deletion
UnchangedNothing changedNothing — we skip it

Because EF Core already tracks all of this, we do not have to write change-detection ourselves. We just ask the ChangeTracker for the list of changed entities right before saving. That is the whole trick.

Figure 2: The lifecycle of a tracked entity. Our audit code reads the state just before the data is written.

Step 1: The audit log entity

First, we need a table to store the recordings. Keep it general so it can describe a change to any entity, not just one type.

public class AuditLog
{
    public Guid Id { get; set; }
 
    // Which table/entity was touched, e.g. "Product"
    public string EntityName { get; set; } = string.Empty;
 
    // "Added", "Modified" or "Deleted"
    public string Action { get; set; } = string.Empty;
 
    // The primary key of the changed row, as text
    public string EntityId { get; set; } = string.Empty;
 
    // Old and new values stored as JSON text
    public string? OldValues { get; set; }
    public string? NewValues { get; set; }
 
    // The CCTV details: who and when
    public string PerformedBy { get; set; } = "system";
    public DateTime PerformedOnUtc { get; set; }
}

Storing old and new values as JSON keeps the table simple. One column can hold any shape of data. You can read it later or even show a "what changed" view in your admin panel.

Step 2: Know who the user is

A CCTV recording is useless if you cannot tell who is in the frame. We need the current user. In ASP.NET Core, the safest way is a tiny service that hides the details of HttpContext.

public interface ICurrentUserService
{
    string UserName { get; }
}
 
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _accessor;
 
    public CurrentUserService(IHttpContextAccessor accessor)
        => _accessor = accessor;
 
    // Read the name from the logged-in user's claims.
    // Fall back to "system" for background jobs with no request.
    public string UserName =>
        _accessor.HttpContext?.User?.Identity?.Name ?? "system";
}

This wrapper has a nice side effect: it is easy to fake in unit tests. You never touch the real HttpContext in a test.

Step 3: The SaveChanges interceptor

Now the heart of the feature. EF Core gives us ISaveChangesInterceptor. We inherit from the helper base class SaveChangesInterceptor and override the method that runs just before the data is written: SavingChangesAsync.

public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;
 
    public AuditInterceptor(ICurrentUserService currentUser)
        => _currentUser = currentUser;
 
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context is not null)
        {
            AddAuditLogs(context);
        }
 
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
 
    private void AddAuditLogs(DbContext context)
    {
        var now = DateTime.UtcNow;
        var user = _currentUser.UserName;
 
        // Ask the ChangeTracker for changed entities,
        // but skip AuditLog itself so we do not audit the audit!
        var entries = context.ChangeTracker.Entries()
            .Where(e => e.Entity is not AuditLog &&
                        (e.State == EntityState.Added ||
                         e.State == EntityState.Modified ||
                         e.State == EntityState.Deleted));
 
        foreach (var entry in entries)
        {
            var log = new AuditLog
            {
                Id = Guid.NewGuid(),
                EntityName = entry.Entity.GetType().Name,
                Action = entry.State.ToString(),
                PerformedBy = user,
                PerformedOnUtc = now,
                EntityId = entry.Properties
                    .FirstOrDefault(p => p.Metadata.IsPrimaryKey())
                    ?.CurrentValue?.ToString() ?? string.Empty
            };
 
            if (entry.State == EntityState.Added)
            {
                log.NewValues = Serialize(entry, current: true);
            }
            else if (entry.State == EntityState.Deleted)
            {
                log.OldValues = Serialize(entry, current: false);
            }
            else // Modified
            {
                log.OldValues = Serialize(entry, current: false);
                log.NewValues = Serialize(entry, current: true);
            }
 
            context.Set<AuditLog>().Add(log);
        }
    }
 
    private static string Serialize(EntityEntry entry, bool current)
    {
        var values = entry.Properties.ToDictionary(
            p => p.Metadata.Name,
            p => current ? p.CurrentValue : p.OriginalValue);
 
        return System.Text.Json.JsonSerializer.Serialize(values);
    }
}

Read that slowly. The important lines are:

  • We grab context.ChangeTracker.Entries() — the list of everything EF Core is tracking.
  • We filter out AuditLog rows, otherwise the audit table would try to audit itself in a loop.
  • For each change we build an AuditLog and add it to the context. Because we add it before the save finishes, EF Core writes the audit row and the real row together, inside the same transaction.

That last point is golden. Either both the change and its audit record are saved, or neither is. You can never end up with a change that has no recording.

Step 4: Register everything

Wire it up in Program.cs. The interceptor needs the user service, so we resolve both from the dependency injection container.

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<AuditInterceptor>();
 
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString);
    options.AddInterceptors(sp.GetRequiredService<AuditInterceptor>());
});

Notice we use the overload that gives us sp (the service provider). That is how the interceptor can read the current user per request. Do not register the interceptor as a singleton if it depends on per-request data.

Audit setup, end to end

AuditLog entity
CurrentUserService
AuditInterceptor
Register in DI
SaveChanges

Steps

1

AuditLog entity

Table that stores the history

2

CurrentUserService

Tells us who is acting

3

AuditInterceptor

Reads ChangeTracker before save

4

Register in DI

AddInterceptors in Program.cs

5

SaveChanges

Real data + audit saved together

The pieces you build and how they connect at startup.

How a single request flows

Let us follow one real change from start to finish. Imagine a shop manager edits a product price.

Figure 3: A price edit travels through the API, the interceptor records it, and both rows are saved in one transaction.

The manager never sees the audit step. It happens silently, exactly like a CCTV camera. But now there is a permanent record: user "asha" changed Product 5 price from 800 to 999 on 10 June 2026.

Choosing what to audit

You usually do not want to audit everything. Logging tables that change a million times a day will bloat your database. A common approach is to mark only the entities you care about with an empty interface and check for it in the interceptor.

public interface IAuditable { }
 
public class Product : IAuditable
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

Then change the filter in the interceptor to e.Entity is IAuditable. Now only auditable entities are recorded. Here is a quick comparison of two strategies.

StrategyGood forWatch out for
Audit everythingSmall apps, strict complianceBig audit table, slower bulk saves
Audit only IAuditableMost real appsYou must remember to mark entities

Should this entity be audited?

Changed entity
Is it AuditLog?
Is it IAuditable?
Record it
Skip it

Steps

1

Changed entity

From the ChangeTracker

2

Is it AuditLog?

Yes: skip, avoid loops

3

Is it IAuditable?

No: skip quietly

4

Record it

Build and add AuditLog

5

Skip it

Leave it alone

A simple decision the interceptor makes for every changed entity.

Common mistakes to avoid

A few traps catch people the first time:

  • Auditing the audit. If you forget to skip AuditLog entities, each audit row triggers another audit row. Always filter them out first.
  • Reading keys too early for new rows. For Added entities, the database generates the primary key after the save. If you need the real id, use SavedChangesAsync (the after-save hook) or read the key from entry.Property once it is set. For simplicity, many teams accept that new-row ids are filled in after insert.
  • Singleton interceptor with scoped data. The current user is per-request. Register the interceptor as scoped so it gets a fresh user service each time.
  • Serializing huge blobs. If an entity has a giant text or byte column, your JSON could be enormous. Exclude such columns from serialization.
  • Forgetting time zones. Always store DateTime.UtcNow. Convert to local time only when you show it to a person.

A note on performance

For a normal web request that changes a handful of rows, the audit work is cheap — usually under 5ms. The cost comes from reflection and JSON serialization, and it only grows when a single SaveChanges touches hundreds of entities at once, like a big import.

If you hit that case, you have options. Audit only the important tables. Skip the JSON for very large objects. Or push audit writing into a background queue so the user's request returns fast while the recording happens a moment later. Start simple, measure, and only optimise if a real slowdown appears.

A quick word on libraries

You do not always have to hand-roll this. Community packages like Audit.NET offer ready-made auditing with many providers. They are great when you want features fast. The hand-built interceptor in this article is still worth knowing, because it is small, transparent, and teaches you exactly how EF Core works under the hood. (Unlike some other .NET libraries such as MediatR and MassTransit, which moved to commercial licensing, a simple interceptor has no licensing cost at all — it is just your own code.)

Quick recap

  • An audit trail is a CCTV camera for your data: it records what changed, who changed it, and when.
  • EF Core's ChangeTracker already knows every change, with states Added, Modified, and Deleted.
  • A SaveChanges interceptor (ISaveChangesInterceptor) is the cleanest place to read those changes and write audit rows.
  • Storing old and new values as JSON keeps the audit table flexible and simple.
  • Audit rows are saved in the same transaction as the real change, so a change can never escape without a record.
  • Inject a small ICurrentUserService to capture who did the action; fall back to "system" for background jobs.
  • Skip AuditLog entities in the interceptor, register it as scoped, and audit only what you need.
  • For most apps the overhead is tiny; only big bulk saves need extra care.

References and further reading

Related Posts