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.
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.
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 state | What it means | What we record |
|---|---|---|
Added | A brand new row is being inserted | The new values |
Modified | An existing row's columns changed | Old values and new values |
Deleted | A row is being removed | The values before deletion |
Unchanged | Nothing changed | Nothing — 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.
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
AuditLogrows, otherwise the audit table would try to audit itself in a loop. - For each change we build an
AuditLogand 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
Steps
AuditLog entity
Table that stores the history
CurrentUserService
Tells us who is acting
AuditInterceptor
Reads ChangeTracker before save
Register in DI
AddInterceptors in Program.cs
SaveChanges
Real data + audit saved together
How a single request flows
Let us follow one real change from start to finish. Imagine a shop manager edits a product price.
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.
| Strategy | Good for | Watch out for |
|---|---|---|
| Audit everything | Small apps, strict compliance | Big audit table, slower bulk saves |
Audit only IAuditable | Most real apps | You must remember to mark entities |
Should this entity be audited?
Steps
Changed entity
From the ChangeTracker
Is it AuditLog?
Yes: skip, avoid loops
Is it IAuditable?
No: skip quietly
Record it
Build and add AuditLog
Skip it
Leave it alone
Common mistakes to avoid
A few traps catch people the first time:
- Auditing the audit. If you forget to skip
AuditLogentities, each audit row triggers another audit row. Always filter them out first. - Reading keys too early for new rows. For
Addedentities, the database generates the primary key after the save. If you need the real id, useSavedChangesAsync(the after-save hook) or read the key fromentry.Propertyonce 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, andDeleted. - 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
AuditLogentities 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
- Interceptors — EF Core (Microsoft Learn)
- Change Tracking in EF Core (Microsoft Learn)
- How to Implement Audit Trail in ASP.NET Core with EF Core — Anton DevTips
- Audit Trail Implementation in ASP.NET Core with EF Core — codewithmukesh
- Tracking Every Change with SaveChanges Interception — Chris Woodruff
Related Posts
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Global Query Filters in EF Core: Soft Delete and Multi-Tenancy Made Easy
Learn EF Core global query filters with simple examples for soft delete and multi-tenancy, plus the new named filters in .NET 10.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
Eager Loading of Child Entities in EF Core: A Beginner's Guide
Learn eager loading in EF Core with Include and ThenInclude. Load child entities in one query, avoid the N+1 problem, and use filtered Include with simple examples.
EF Core DbContext Options Explained: A Beginner's Friendly Guide
Learn EF Core DbContext options in simple words: AddDbContext, the options builder, retry on failure, query splitting, logging, lifetimes and pooling, with diagrams and examples.
EF Core Raw SQL Queries: FromSql, SqlQuery, and ExecuteSql Explained
A friendly, beginner guide to raw SQL in EF Core: FromSql for entities, SqlQuery for scalars, ExecuteSql for writes, and how to stay safe from SQL injection.