←All insights
.NET · Multi-tenancy6 min readMar 2026

Tenant isolation in EF Core without trusting every query

Resolve the tenant once, filter it in the model, stamp it on save, and know the four places EF Core will happily ignore all of that.

In a shared-database, multi-tenant system every tenant-owned table has a TenantId column, and every query must filter on it. "Every query" is the problem. A codebase with a few hundred queries written by several people over a few years will eventually have one that forgets, and that one shows a customer another customer's meters, invoices or users. I would rather the filter be the default that someone has to go out of their way to remove, and that removing it is easy to find in review.

This is the pattern I use on a multi-tenant platform I work on at Savtech: .NET 8, ASP.NET Core with Razor Pages, EF Core 8 and SQL Server. Tenants have subscription plans, plans grant permissions, and the menu a user sees depends on both. The design below is simplified from that platform, not copied from it.

The shape of it

Where the tenant comes from
Request
Auth cookietenant_id claim
Hangfire jobtenantId argument
Scope
Middlewaresets TenantContext
Job bodysets TenantContext
EF Core
Query filterreads CurrentTenantId
SaveChangesstamps TenantId
SQL Server
Tenant tablesTenantId column + index

Two things set the tenant, and only two things read it. Everything in between, the Razor Pages, services and repositories, never mentions TenantId at all.

Resolving the tenant once per request

The tenant lives in a scoped service. The app sees it through a read-only interface; only the code that resolves the tenant gets the concrete type with a setter.

Middleware reads the tenant_id claim from the signed-in user and, if it parses as a GUID, sets it on the scoped context.

I take the tenant from a claim written at sign-in, not from a subdomain or a route value. A subdomain tells you which tenant the user asked for, and a claim tells you which tenant they belong to. For most apps the second is the one that matters, and it cannot be changed by editing a URL. The middleware has to run after authentication, or the user has no claims yet, and before authorisation.

The setter refuses to change tenants inside a scope. Setting the same tenant twice is fine; setting a different one throws. That has caught one real bug for me: a job that looped over tenants with a single scope.

The filter lives in the model

Every tenant-owned entity implements a marker interface, ITenantOwned, which says only that it has a TenantId. When EF Core builds the model, the context walks every entity type, and for each one that carries the marker it adds two things: a query filter comparing the row's tenant to the current one, and an index on TenantId. Nobody writes the filter by hand for a new entity. Adding the interface is enough.

When there is no current tenant, the filter compares each row against null, which matches nothing. A request with no tenant gets empty results, not everyone's. That is the direction I want it to fail in.

The filter has to reference a property on the context, not a value copied out of it. EF Core builds the model once per context type and caches it. When the filter points at the context's CurrentTenantId property, EF treats it as a parameter and reads it from the current context instance for each query. More on what happens if you get that wrong below.

Unique indexes need the tenant too. A meter serial number is unique within a tenant, not across the platform, so its index covers the tenant and the serial number together.

Stamping TenantId on save

Filtering reads is half of it. The other half is making sure a new row cannot be saved without a tenant, or with the wrong one.

Read path and write path
Code
LINQ queryany DbSet
New entityadded, then saved
Context
Query filtercurrent tenant only
SaveChangesstamps TenantId
Guard rails
No tenantreads empty, inserts throw
Other tenantinsert throws
TenantId editedupdate throws
SQL Server
Tenant tablesindexed on TenantId

Before every save, the context looks at each tenant-owned entity the change tracker holds. A new entity gets the current tenant written onto it. If there is no current tenant, the save throws. If the entity already carries a different tenant, the save throws. For an existing entity, any change to its TenantId throws, because a row never moves between tenants.

I override the two save methods that take the accept-all-changes flag. The parameterless versions call through to them, so that covers all four. A SaveChangesInterceptor would do the same job and keeps the context class smaller; I use the override because the rule then sits next to the filter it complements.

Background jobs have no request

Hangfire runs jobs on a worker with no HttpContext, so the middleware never runs and the tenant is null. With the filter failing closed, a job that forgets to set it does nothing instead of doing something to everyone. That is safe but quiet, so the convention is that every tenant-scoped job takes the tenant id as its first argument and sets it before touching data.

One job per tenant
Schedule
Recurring jobfan-out only
Fan-out
Active tenantsTenant table, unfiltered
Queue
Tenant A jobtenantId first
Tenant B jobtenantId first
Job scope
TenantContextset before data
AppDbContextfiltered as usual

The recurring job is the fan-out, not the work. It reads the list of active tenants, which is not filtered because the tenant entity itself is not tenant-owned, and enqueues one daily rollup job per tenant for the previous day. Each sets its tenant, then queries and saves like any other code.

Hangfire's ASP.NET Core activator creates a DI scope per job, so each job gets its own tenant context and its own DbContext. One job per tenant also means one tenant's bad data fails one job, and the retry is for that tenant alone. The enqueued call passes an empty cancellation token, and Hangfire replaces it with its own shutdown token when the job runs.

The DbContext is constructed before the tenant is set, which is fine. The filter reads the tenant when the query runs, not when the context is built.

Where it still leaks

Key idea

The query filter protects LINQ queries against entity sets. Everything else is on you.

These are the ones I have seen or nearly shipped.

  • ›IgnoreQueryFilters(). In EF Core 8 an entity has one query filter, so if an entity also soft-deletes, both conditions live in that one expression and this removes them together. Legitimate uses exist, but each one should be in a small number of places that review can find with a search.
  • ›Raw SQL outside a DbSet. FromSql on a DbSet is composed into a subquery and still gets the filter. Database.SqlQuery, ExecuteSql, stored procedures and Dapper on the same connection get nothing. Every one of those needs an explicit tenant parameter.
  • ›Values baked into the cached model. Copy the tenant into a local variable and use that in the filter, and the first tenant to hit the app after startup is captured as a constant in the model that every later request shares. It works perfectly in a single-tenant test.
  • ›Context pooling. AddDbContextPool reuses context instances across requests, and a pooled context may only take its options in its constructor, so a scoped tenant context cannot be injected that way. Either stay on AddDbContext, or use a pooled factory that sets the tenant on each context it hands out.
  • ›Child tables without TenantId. If meter readings hang off meters and have no tenant column, a lookup by reading id is unfiltered. I put TenantId on every tenant-owned table, even when it duplicates the parent's. It costs a column and saves a join in every filter.

Super-admins across tenants

Platform staff need to see every tenant: onboarding, plan changes, support. I do not give the filter a super-admin escape hatch, because then every query has a second path. Cross-tenant reads go through one service.

The platform admin path
Caller
Platform staffPlatformAdmin role
Service
Role checkinside the service
Audit logone row per read
Query
Filters ignoredone known place
Soft deleteadded back by hand
Result
DTO projectionnot tracked

The service checks the role itself rather than relying on the page, writes an audit row naming the user, the action and the tenant, and only then queries. It switches the filters off, puts back the soft-delete condition that doing so removed, and projects the results into plain DTOs, so nothing it returns is tracked and saved back by accident.

Writes on behalf of a tenant are done differently: open a new scope, set the target tenant on it, and use the normal code path. That way the save-time checks still apply.

What I'd do differently

  • ›Add the tenant tests on day one. The test that would have saved the most time seeds two tenants, sets the context to the first, and asserts every DbSet on the context returns no rows belonging to the second. Generated by reflection over the model, it catches a new entity that forgot ITenantOwned, which is the most common way this pattern fails.
  • ›Enforce the leak list with an analyser. A Roslyn analyser that flags IgnoreQueryFilters, SqlQuery and ExecuteSql outside an allow-listed namespace is cheaper than remembering to look for them in review.
  • ›Add SQL Server row-level security underneath. A security policy keyed on the tenant id in SESSION_CONTEXT, set by a connection interceptor, would cover the raw SQL and Dapper paths that EF's filter cannot. I left it out at first because of the extra moving parts, and the raw SQL leak is the one I worry about most.
  • ›Upgrade for named filters. EF Core 10 allows several named filters per entity, so ignoring soft delete no longer has to switch off tenant isolation as well.
Available for new workBuilding something like this?I'm open to full-time roles and freelance work, remote or relocation.
↑