# 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

```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

```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

```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.
