Multi-tenant data isolation with EF Core global query filters
How to build per-tenant isolation without repeating WHERE TenantId in every query — and the gaps that still leak data.
How to build per-tenant isolation without repeating WHERE TenantId in every query — and the gaps that still leak data.
In a multi-tenant SaaS, the most dangerous class of bug is not the one that throws an exception. It is the one that returns data. Someone else’s data.
The risk comes from repetition: if isolation depends on every developer remembering to add WHERE TenantId = ..., it is only a matter of time before someone forgets. EF Core’s global query filters move the rule into one place, where it cannot be forgotten.
This article is about using them correctly — and, more importantly, about what they do not cover.
You declare a condition once, at model build time, and it is appended automatically to every LINQ query against that entity:
modelBuilder.Entity<TimeEntry>()
.HasQueryFilter(e => e.TenantId == _tenantId);
From then on, context.TimeEntries.ToListAsync() generates SQL with the filter included. So do navigations into that entity. The practical effect: isolation becomes implicit rather than a matter of individual discipline.
The important detail is that _tenantId must be a field on the context, not a local variable captured at configuration time. EF builds the expression once but reads the field at execution — so each context instance brings its own tenant.
The pattern that works: a scoped service that resolves the tenant from the request (subdomain, token claim, header) and is injected into the context.
public class AppDbContext : DbContext
{
private readonly Guid _tenantId;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantProvider tenant)
: base(options) => _tenantId = tenant.TenantId;
}
Two rules I learned the hard way:
This is trap number one, and it is counter-intuitive: filters apply to queries, not to inserts.
If something saves an entity with no TenantId, or with someone else’s, EF writes it happily. That record then becomes invisible to the correct tenant and visible to the wrong one.
The fix is to stamp and validate in SaveChanges:
Two details decide whether this actually works.
Override the right methods. DbContext has four public save entry points, but the parameterless ones delegate into these two. Override only SaveChangesAsync(CancellationToken) and a plain synchronous SaveChanges() walks straight past your guard:
// Both public entry points funnel through these two overloads.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
GuardTenant();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
GuardTenant();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
Check the original value, not the current one. This is the subtle half:
private void GuardTenant()
{
foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.TenantId = _tenantId;
continue;
}
if (entry.State is not (EntityState.Modified or EntityState.Deleted)) continue;
var tenant = entry.Property(e => e.TenantId);
// Comparing the CURRENT value would let this through: load B's row,
// set TenantId = A, save. Current == mine, so it would pass.
if (!Equals(tenant.OriginalValue, _tenantId) || tenant.IsModified)
throw new InvalidOperationException("Cross-tenant write blocked.");
}
}
The throw is the part that matters. You are not merely filling in a missing tenant — you are refusing the write when something tries to modify another tenant’s entity, or to move one of yours to another tenant.
| Gap | Why it slips through | What you do |
|---|---|---|
| Unfiltered writes | Filters only apply to reads | Stamp + validate in SaveChanges (above) |
IgnoreQueryFilters() |
Disables all unnamed filters at once | Banned in application code; allowed only in reviewed system jobs |
ExecuteUpdate / ExecuteDelete |
Bypass the change tracker, so the SaveChanges guard never runs |
Never set TenantId through them; review every usage |
ExecuteSql*, SqlQuery*, Dapper/ADO.NET |
Never see the EF model at all | Write the predicate by hand |
| Required navigations | An inner join into a filtered principal can make rows disappear | Make navigations optional, or filter both ends consistently |
| Lookup by key | FindAsync can return a tracked instance without re-applying the filter |
Use FirstOrDefaultAsync on tenant-owned entities |
Two clarifications worth having, because the common advice is wrong on both:
FromSql* is filtered. Unlike ExecuteSqlRaw or Database.SqlQueryRaw<T>, a FromSql on a DbSet composes into the LINQ pipeline: EF wraps your statement as a subquery and appends the filter predicate. It is the raw-SQL path that stays safe. ExecuteUpdate/ExecuteDelete also respect the filter in their WHERE — but they skip the change tracker, so nothing stops them writing a different tenant’s id into your own rows.
FindAsync is the subtlest one. It checks the change tracker first; if the entity is already tracked, it comes back directly, without re-evaluating the filter. For that to matter, a foreign-tenant entity has to be tracked already — which happens via IgnoreQueryFilters(), an Attach(), raw-SQL materialisation, or a context reused across tenants.
Usually you want both conditions: tenant and “not deleted”. Be careful here: two unnamed HasQueryFilter calls do not compose — the second silently replaces the first. That is still true in EF Core 10, and it has caused plenty of quiet data leaks. Combine them explicitly:
.HasQueryFilter(e => e.TenantId == _tenantId && !e.IsDeleted);
EF Core 10 added named filters, which do compose, and let you drop one selectively with IgnoreQueryFilters(["SoftDelete"]) instead of disabling everything. That selective overload is worth knowing: it means “show me deleted rows” no longer has to mean “show me every tenant’s deleted rows”.
This is the difference between “I think it is isolated” and “I know it is”. The test I always write:
TenantId and assert that it throws.And for real coverage, a test that iterates every tenant-owned entity and asserts a filter is configured — not just the ones you thought of. That is the test that catches the new entity someone else adds next month.
Query filters give you logical isolation inside a shared database. That is a very good fit for most products, but it is not the only option — and when requirements demand separation the engine enforces rather than your code, the discussion moves to a database per tenant. I wrote about those trade-offs separately.
If you are building a multi-tenant SaaS and want a second opinion on the isolation model, that is exactly the kind of problem I have worked on across several products.
If you are building in this space, let us talk for 30 minutes.
Book a call