Stop Leaking Tenant Data: Multi-Tenant EF Core in .NET 10
Your multi-tenant EF Core app might be leaking one customer's data into another customer's response right now - here's how EF Core 10's named global query filters fix that for good.
Connect with me:
🚀 Sponsored
Struggling with slow EF Core operations? With ZZZ Projects’ EF Core Extensions, you can boost performance like never before. Experience up to 14× faster Bulk Insert, Update, Delete, and Merge - and cut your save time by as much as 94%.
👉 entityframework-extensions.net
Introduction
Most SaaS apps don’t give every customer their own database. One database, one schema, every tenant’s rows sitting side by side, distinguished only by a TenantId column. It’s cheaper to run, easier to migrate, easier to back up. But it means the only thing separating Customer A’s data from Customer B’s data is your application code getting the filter right - every query, every time, forever. One missed condition and you’ve mixed up two customers’ financial records. That’s not a bug ticket, that’s a breach notification.
The usual fix - “remember to add a Where TenantId == clause to every query” - doesn’t hold up, because eventually someone forgets, in some endpoint, at 4pm on a Friday. The real fix is making it impossible to forget: push tenant isolation down into EF Core itself, so a query that “forgets” the filter is a query that can’t run unscoped in the first place.
🎬 Watch the full video here:
🔑 Provisioning Real Tenants, Not a Dev-Token Shortcut
Before any of the isolation logic matters, there have to be two real tenants with real logins to leak between - otherwise the whole demo is just asserting a claim instead of showing it. Tenant creation is deliberately not self-service: if registration could take a company name and spin up a brand-new tenant for anyone who asks, that’s an open spam vector with zero access control. So tenant creation is a platform-admin-only endpoint, followed by an admin-only invitation endpoint that takes the tenant ID explicitly, and a registration endpoint that takes an invite token instead of a company name.
That ordering matters more than it looks - TenantId on the new user always comes from the invitation record an admin already tied to a tenant, never from anything the client sends directly. If a client could pass a raw TenantId at registration, anyone could self-register straight into a competitor’s data - a preview of the exact bug the rest of the video fixes, just one layer earlier.
⚙️ Marking Which Entities Actually Need Isolation
Not every table in a multi-tenant app needs to be tenant-scoped - reference and lookup tables usually aren’t. Instead of hardcoding checks per entity, a small marker interface expresses “this entity belongs to exactly one tenant,” and both the order entity and the invitation entity implement it. That single interface is what lets the query filter and the save-side stamping target “anything tenant-scoped” instead of being copy-pasted per entity.
Resolving “Who Is the Current Tenant” from a JWT Claim
The DbContext needs to know the current tenant without every endpoint passing it in manually. That starts at login, where a tenant_id claim gets added to the JWT. A scoped tenant service then reads that claim back out of the current request on every call and exposes it as a plain property.
The DI lifetime here isn’t a minor detail - this service must be registered scoped, not singleton. Register it as a singleton by mistake and every request after the first gets permanently stuck resolving the first request’s tenant - a subtle bug that only shows up once two different users hit the API back to back.
One gotcha worth knowing before it bites you: ASP.NET Core’s JWT bearer handler silently renames well-known inbound claim types by default - a standard sub claim comes back mapped to a long URI instead of the string you’d expect. A custom claim name like tenant_id is unaffected, but if a claims lookup you’re sure should work comes back empty for no visible reason, check MapInboundClaims = false on the JWT bearer options before anything else.
🎯 The Actual Fix: A Named Global Query Filter
This is the core of the episode. EF Core lets you attach a filter predicate to an entity type in OnModelCreating, and it gets applied to every query against that entity automatically - no Where clause, no opt-in, per query, required. In EF Core 10, that filter also gets a name:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Order>()
.HasQueryFilter("TenantFilter", o => o.TenantId == _currentTenantService.TenantId);
}That name is the whole reason this is worth a dedicated video instead of just “here’s HasQueryFilter, the thing you might already know.” Before EF Core 10, HasQueryFilter only ever kept one predicate per entity - call it a second time and the second call silently discards the first. That made it impossible to cleanly layer two independent concerns (tenant isolation and soft delete, say) onto the same entity without hand-merging both conditions into a single lambda. A named filter fixes that, and the same filter name can be reused cleanly across different entities too - the invitation entity gets the identical filter name and predicate shape, so every list of a tenant’s pending invitations is automatically scoped too.
Reusing the name across entities has a real consequence, though: the invite-lookup query inside registration runs with no tenant context at all, because resolving tenant context is the entire point of that call - nobody’s logged in yet. Once the invitation entity picks up the filter, that lookup needs to explicitly opt out with the named IgnoreQueryFilters(["TenantFilter"]) overload.
With the filter and the escape hatch both in place, repeating the exact leak from the hook - a user from one company calling the orders endpoint - now returns only that company’s orders. Nothing changed in the endpoint or the query itself. The filter is enforced at the model level, so the safety doesn’t depend on whoever wrote that endpoint remembering anything.
Closing the Write-Side Gap
A read-side filter only solves half the problem. Nothing stops a developer from creating a record and forgetting to set TenantId, or setting the wrong one by accident. The fix is to stamp it automatically, in an overridden SaveChangesAsync, by walking every tracked tenant-scoped entity being added and filling in TenantId from the current tenant service whenever it’s still unset:
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ITenant>())
{
if (entry.State == EntityState.Added && entry.Entity.TenantId == Guid.Empty)
{
entry.Entity.TenantId = _currentTenantService.TenantId;
}
}
return base.SaveChangesAsync(cancellationToken);
}Because that loop targets the marker interface rather than a concrete entity type, the invitation entity picks up the exact same protection for free - the actual payoff of introducing the marker interface back at the start instead of hardcoding entity types throughout. TenantId stops being something any endpoint has to think about, on read or on write.
TenantId vs. CreatedByUserId: Two Different Axes
Once a tenant has more than one member, a second question shows up that TenantId alone can’t answer: within a shared tenant, can a user tell which rows are theirs? That’s a completely different axis - not “which company,” but “which person in that company” - and it deliberately does not become a second global query filter. CreatedByUserId gets stamped automatically the same way TenantId does, but an endpoint that returns “my orders” filters by it with a plain LINQ Where, because the main orders endpoint still needs to return every coworker’s orders on purpose.
The distinction is worth internalizing beyond this one endpoint: a global query filter is for an invariant that must hold on every query, no exceptions, because getting it wrong is a security incident. A per-endpoint Where is a convenience view over data the caller is already allowed to see. TenantId is a security boundary; CreatedByUserId is metadata - they look almost identical on the entity but answer fundamentally different questions.
🔓 The Escape Hatch, Used Deliberately
Sometimes crossing tenants is legitimate - an internal admin dashboard totaling activity across every customer, for example. EF Core’s answer is IgnoreQueryFilters(), and the video is deliberate about calling it with the filter’s name rather than bare:
group.MapGet("admin/all", async (AppDbContext db) =>
{
var orders = await db.Orders.IgnoreQueryFilters(["TenantFilter"]).ToListAsync();
return Results.Ok(orders);
}).RequireAuthorization(policy => policy.RequireRole(DbSeeder.PlatformAdminRole));With only one filter on the entity today, naming it changes nothing functionally. It matters the moment a second named filter - soft delete is the obvious candidate - gets added to the same entity later: a bare IgnoreQueryFilters() would silently switch that filter off too, in whatever endpoint happens to call it. Because disabling a safety net is the one place in the codebase where a guarantee is deliberately turned off, the endpoint that calls it carries its own explicit authorization check - that’s not optional, and the code above shows it isn’t just a suggestion: RequireAuthorization is right there on the same endpoint, not off in a comment somewhere.
⚠️ Where the Guarantee Can Still Break
Raw SQL and other data-access tools bypass it entirely. If any part of the app reads through Dapper,
FromSqlRaw, or a reporting tool instead of LINQ against theDbContext, EF Core can’t inject aWhereclause into SQL text it didn’t generate.An unindexed
TenantIdcolumn turns every query into a table scan. Add the index (ideally composite with whatever else gets filtered or sorted on) in the same migration that adds the filter, not as an afterthought.IgnoreQueryFilters()outlives the endpoint it was written for. It’s easy to copy an admin query as a starting point for something else and forget to remove the bypass, or forget the authorization check that was guarding it.Named filters require EF Core 10. Teams still on EF Core 9 or earlier only get the unnamed overload, which keeps the last predicate registered and silently drops any before it.
Shared-schema-with-
TenantIdisn’t the only option. If a compliance requirement mandates physical isolation, schema-per-tenant or database-per-tenant trade operational complexity for a stronger guarantee.
Key Takeaways
A missing
Where TenantId ==clause in a shared-schema multi-tenant app is a data breach waiting to happen, not a bug ticket - the fix has to make the mistake structurally impossible, not just documentedEF Core 10’s named
HasQueryFilter("Name", predicate)scopes every query against an entity automatically, and unlike the pre-10 unnamed overload, multiple named filters can coexist on the same entity instead of the last call silently winningThe same filter name can be reused across entities that share an isolation rule, but each new entity that picks it up needs an audit of every existing query against it for the no-tenant-context case
Reads and writes both need protection: a global query filter handles every read automatically, and an overridden
SaveChangesAsyncstampsTenantIdon every insert so it’s never left unsetTenantId(which company, a security boundary, enforced everywhere) andCreatedByUserId(which person, a convenience, filtered only where it matters) look similar but answer different questions - only one of them belongs in a global query filterIgnoreQueryFilters()should always be named and always be paired with an explicit authorization check - it’s the one place a safety net gets deliberately switched offRaw SQL/Dapper reads and stray
IgnoreQueryFilters()calls are the two places this guarantee can still quietly break
Connect with me
Follow me on LinkedIn
Subscribe on YouTube
Want to sponsor this newsletter? Let’s work together →
