Multi-Tenancy in .NET: Three Ways to Keep Customers’ Data Apart

The scariest bug in a multi-tenant system doesn’t crash anything. It returns the right data to the wrong customer, and nobody notices until that customer does. I treat tenant isolation as an architecture decision made early, because retrofitting it into a live system is one of the more painful migrations I know of.
The three models
The first option is a shared database where every table has a tenant column. It is the cheapest to run and the simplest to operate, and it has the weakest isolation, because one forgotten WHERE clause exposes everyone’s data. The second is a schema per tenant, which sits in the middle: better separation, but migrations get heavier as tenants multiply. The third is a database per tenant, which gives the strongest isolation and makes per-customer backup and restore straightforward. The cost is money and operational effort that grow with every tenant, since migrations now have to run against every database.
I choose based on three things: what customers and regulators require about isolation, how many tenants there will be, and how different in size they are. For many small tenants I default to a shared database with strict enforcement. Where a customer requires hard isolation, they get their own database.
Resolving the tenant safely
A middleware should work out who the tenant is before any business code runs, registered after authentication. The important rule is to take the tenant from a claim in a validated token, not from a header the client controls, because a header can simply be changed to another tenant’s ID.
csharp
public class TenantMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, ITenantContext tenant)
{
var tenantId = context.User.FindFirst("tenant_id")?.Value;
if (tenantId is null)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
tenant.Set(tenantId);
await next(context);
}
}
Enforcing it in EF Core
A global query filter adds the tenant condition to every query for an entity, so nobody has to remember it.
csharp
public class AppDbContext : DbContext
{
private readonly ITenantContext _tenant;
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenant)
: base(options) => _tenant = tenant;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(o => o.TenantId == _tenant.TenantId);
}
}
Filters only protect reads, so the tenant on writes should be set in exactly one place:
csharp
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<ITenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.TenantId = _tenant.TenantId;
}
return base.SaveChangesAsync(ct);
}
Two things bypass this protection: IgnoreQueryFilters() and hand-written SQL. I treat both as review triggers. As a second layer, SQL Server’s row-level security can enforce the same rule inside the database. You set SESSION_CONTEXT when a connection opens and attach a security policy, so a mistake in application code still can’t cross the boundary.
What quietly leaks
The data layer is rarely where the leak happens. Cache keys without the tenant ID are the classic one: tenant A caches a result and tenant B receives it. Background jobs are another, because a job runs outside the request and has no tenant context unless you pass it in explicitly. File storage paths and logs deserve the same scrutiny.
Test the boundary directly
The cheapest insurance I know is an integration test that creates data as tenant A, then calls every endpoint as tenant B and asserts that nothing comes back. When that test lives in the pipeline, a missing filter fails the build instead of reaching a customer.
Comments
Join the discussionNo comments yet. Be the first to comment.



