Remigiusz ZalewskiRemigiusz Zalewski

Stop Leaking Tenant Data: Multi-Tenant EF Core in .NET 10

multi-tenant-ef-core-global-query-filters

ZZZ Projects' EF Core Extensions Struggling with slow EF Core operations? 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%. Learn more →

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. That's what this video builds, on top of real accounts - real register, real login, real invites - not a dev-token shortcut, because the moment tenant isolation touches "who's actually logged in," a demo built on a fake token is lying to you about what it's actually proving.

🎬 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 here is deliberately not self-service. If /auth/register could take a companyName and spin up a brand-new tenant for anyone who asks, that's an open spam vector - unlimited tenants, zero access control. Real SaaS onboarding is provisioned by sales/ops or gated behind approval, so tenant creation is a platform-admin-only endpoint.

Getting the first member into a brand-new tenant needs its own step too, since there's no existing member to invite through yet: an admin-only invitation endpoint that takes the tenant ID explicitly, followed by a registration endpoint that takes an inviteToken instead of a companyName. That ordering matters more than it looks - TenantId on the new user always comes from the invitation record that was already tied to a tenant by an admin, 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, which is a preview of the exact bug the rest of the video fixes, just one layer earlier.

With that in place, two tenants (Contoso and Fabrikam) get created, an admin invites one real user into each, and both redeem their invites and log in - two real accounts, two real tenants, zero shortcuts. Calling GET /orders as one of them and getting the other tenant's invoices back is the leak this whole episode exists to close.


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 Order everywhere a tenant check is needed, a small marker interface expresses "this entity belongs to exactly one tenant":

public interface ITenant
{
    Guid TenantId { get; set; }
}

Both Order and TenantInvitation implement it. That single interface is what lets the rest of the solution - 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 alongside the standard claims. A scoped ICurrentTenantService then reads that claim back out of HttpContext.User on every request and exposes it as a plain TenantId 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, which is a subtle, hard-to-catch 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 AddJwtBearer silently renames well-known inbound claim types by default - a standard sub claim comes back mapped to the long ClaimTypes.NameIdentifier 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 options.MapInboundClaims = false on the JWT bearer handler 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:

modelBuilder.Entity<Order>()
    .HasQueryFilter("TenantFilter", o => o.TenantId == currentTenant.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 it also means the same filter name can be reused cleanly across different entities - TenantInvitation gets the identical "TenantFilter" 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 /auth/register runs with no tenant context at all, because resolving tenant context is the entire point of that call - nobody's logged in yet. Once TenantInvitation picks up the filter, that lookup needs to explicitly opt out with the named overload:

db.TenantInvitations.IgnoreQueryFilters(["TenantFilter"])

With the filter and the escape hatch both in place, repeating the exact leak from the hook - Alice, logged in as Contoso, calling GET /orders - now returns only Contoso's orders. Nothing changed in the endpoint or the query written against db.Orders. 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 an Order 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 ITenant entity that's being added and filling in TenantId from the current tenant service whenever it's still unset.

Because that loop targets the ITenant interface rather than the Order type directly, TenantInvitation picks up the exact same protection for free - that's 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. The isolation becomes structural instead of conventional.

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 in SaveChangesAsync the same way TenantId does, resolved from a second scoped service reading the user's own ID off the JWT. But an endpoint like GET /orders/mine filters by it with a plain LINQ Where, not HasQueryFilter, because GET /orders still needs to return every coworker's orders on purpose - that's the whole point of a shared tenant workspace.

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 get stamped identically and look almost identical on the entity, but they answer fundamentally different questions - and mixing them up in either direction is a mistake worth catching in review.

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:

db.Orders.IgnoreQueryFilters(["TenantFilter"])

With only one filter on Order 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, possibly one nobody's looking at closely. Targeting the name keeps that line doing exactly one thing no matter what else gets layered onto the entity down the line. And 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 it should live in as few places as possible.


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 the DbContext, EF Core can't inject a Where clause into SQL text it didn't generate - that's a second isolation boundary that needs its own explicit plan, not an assumption that the filter "just applies everywhere."
  • An unindexed TenantId column turns every query into a table scan. The filter adds an extra WHERE TenantId = @tenantId to every single query against the entity - 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. It should show up in a handful of places in a codebase, not dozens - worth grepping for periodically.
  • Named filters require EF Core 10. Teams still on EF Core 9 or earlier only get the unnamed HasQueryFilter overload, which keeps the last predicate registered and silently drops any before it - the "name it, stack more later" approach in this video needs the upgrade first.
  • Shared-schema-with-TenantId isn'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 - a bigger topic on its own, but worth knowing as the next step up.

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 documented
  • EF 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 winning
  • The same filter name can be reused across different 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 (like an invite lookup during registration)
  • Reads and writes both need protection: a global query filter handles every read automatically, and an overridden SaveChangesAsync (or an interceptor) stamps TenantId on every insert so it's never left unset
  • TenantId (which company, a security boundary, enforced everywhere) and CreatedByUserId (which person, a convenience, filtered only where it matters) look similar but answer different questions - only one of them belongs in a global query filter
  • IgnoreQueryFilters() should always be named and always be paired with an explicit authorization check - it's the one place a safety net gets deliberately switched off
  • Raw SQL/Dapper reads and stray IgnoreQueryFilters() calls are the two places this guarantee can still quietly break

Get the Full Source Code

The full runnable project - the tenant and invitation entities, the JWT claim wiring, the named query filter, and the SaveChangesAsync stamping, all working end to end against ASP.NET Core Identity - is available to Patreon supporters. If you want to run it yourself instead of piecing it back together from the walkthrough above, you can find it on Patreon.

Resources