Every multi-tenant app has the same bug waiting to happen. Someone adds an endpoint late on a Friday:
app.MapGet("/appointments/today", (AppDb db) => { var today = DateTime.UtcNow.Date; return db.Appointments.Where(a => a.StartsAt >= today).ToListAsync(); }); It works, the tests pass and it ships. It also returns every customer's appointments, because nobody wrote && a.TenantId == currentTenant.
I'm building StrideWell, a multi-tenant platform for gyms and personal trainers, on my own. The API has close to 100 database entities and more than 600 endpoints. Remembering the tenant filter on every query wasn't going to work, so I made the database layer remember it for me. I've now extracted that code into an open-source library: ModelHub.MultiTenancy.
dotnet add package ModelHub.MultiTenancy dotnet add package ModelHub.MultiTenancy.EntityFrameworkCore The idea: the tenant comes from the token
In most SaaS apps the signed-in user's token already says which tenant they belong to. So instead of resolving tenants from hostnames or a tenant store, the library reads a claim:
builder.Services.AddMultiTenancy(o => { o.TenantIdClaimType = "tenant_id"; // default o.PlatformAdminRoles.Add("support"); o.TenantAdminRoles.Add("practice_manager"); o.SubTenantScopedRoles.Add("receptionist"); }); app.UseAuthentication(); app.UseTenantResolution(); // after authentication, before authorization app.UseAuthorization(); Anything that needs the current tenant injects ITenantContext, which is scoped to the request.
A worked example: a vet practice SaaS
Imagine VetDesk, practice-management software for groups of vet clinics. Each practice group is a tenant and each clinic is a sub-tenant. Northfield Vets (Leeds and York) and Riverside Animal Care share one database.
public class Appointment : ITenantEntity, ISubTenantEntity { public int Id { get; set; } public Guid TenantId { get; set; } // the practice group public Guid? SubTenantId { get; set; } // the clinic public string PetName { get; set; } = ""; public DateTime StartsAt { get; set; } } public class VetDb(DbContextOptions<VetDb> options, ITenantContext tenantContext) : DbContext(options), ITenantAwareDbContext { public ITenantContext TenantContext { get; } = tenantContext; public DbSet<Appointment> Appointments => Set<Appointment>(); protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyTenantQueryFilters(this); } builder.Services.AddDbContext<VetDb>(o => o.UseSqlServer(conn).UseMultiTenancy()); Here's what that buys you.
1. The forgotten filter can't leak. The Friday-afternoon endpoint above now returns Northfield's appointments only. EF Core adds the tenant condition to the SQL for every entity that implements ITenantEntity, so nobody has to remember the Where. A request with no tenant gets no rows, not all of them.
2. Sub-tenants. A receptionist at York has a token with role=receptionist and sub_tenant_id=<York>:
var diary = await db.Appointments.WhereVisibleToSubTenants(tenant).ToListAsync(); The receptionist sees York. The practice manager runs the same code and sees both clinics.
3. Writes are protected too. Read filters are only half the story. A faulty CSV import running as Northfield tries to save a row stamped with Riverside's id:
db.Appointments.Add(new Appointment { TenantId = riversideId, PetName = "Biscuit" }); await db.SaveChangesAsync(); // TenantMismatchException: Cannot add Appointment: it belongs to a different tenant. A save interceptor checks every added, modified and deleted tenant entity before anything reaches the database. New rows without a TenantId get the current tenant stamped on them automatically. Moving a row to another tenant is refused.
4. Support can help without seeing everything. A support engineer with a platform-admin role can send X-Tenant-Id: <Northfield> to act as that tenant. For that request their cross-tenant bypass is switched off, so they see exactly what Northfield sees, and the impersonation is logged. Anyone else sending the header is ignored, and that's logged too.
How the filter works under the hood
EF Core builds the model once and caches it, so how can one model filter by a different tenant on every request?
The trick is that EF Core treats a reference to the DbContext instance inside a query filter specially. When ApplyTenantQueryFilters(this) builds the filter expression, it points at the context's TenantContext property:
e => ctx.TenantContext.IsPlatformAdmin || e.TenantId == ctx.TenantContext.TenantId At query time EF Core swaps in the current context instance and turns TenantId into a SQL parameter. One cached model, a different tenant per request, and no string concatenation.
Two version differences mattered:
- EF Core 10 supports named query filters, so the tenant filter is added as
ModelHub.MultiTenancy.Tenantand sits alongside your own filters (soft delete, for example). - EF Core 8 allows one filter per entity, so the library combines with any filter already declared. Call
ApplyTenantQueryFilterslast inOnModelCreating.
The test suite runs on both, including a test that reuses one cached model for two tenants back to back and checks nothing leaks between them.
Design decisions
- Safe by default. No tenant claim means no tenant rows. A malformed claim is treated as no tenant, never as "all tenants".
- Set once per scope. In background jobs you call
TenantContext.SetTenant(id)yourself. A second call throws, so one tenant's job can't quietly switch to another mid-way. - Small surface area. One interface, one middleware, one model-builder call, one interceptor.
Limitations, honestly
-
IgnoreQueryFilters()turns the protection off. Treat it likesudo. - Raw SQL that bypasses LINQ (Dapper,
ExecuteSqlRaw) isn't filtered. -
ExecuteUpdateandExecuteDeleterespect the query filter, but they skipSaveChanges, so the write checks in the interceptor don't run. - With DbContext pooling, make sure each context gets the request's
ITenantContext.
If you resolve tenants from hostnames or need per-tenant databases, Finbuckle.MultiTenant is the mature choice. This library is deliberately narrower: the common case where the token already knows the tenant.
Try it
- GitHub: https://github.com/modelhub-solutions/ModelHub.MultiTenancy (a sample minimal API with JWT is in
/samples) - NuGet: https://www.nuget.org/packages/ModelHub.MultiTenancy
- Targets .NET 8 and .NET 10, MIT licence
It's version 0.1, so feedback is very welcome. Issues and pull requests are open, and the issues labelled good first issue are a good way in. If you run a multi-tenant .NET app, I'd love to hear how you handle tenant isolation today.
I'm Kehinde, founder of ModelHub Solutions in Wales. I'm building StrideWell, a platform for gyms and coaches, and open-sourcing the parts that might help other teams.




