DEV Community

Cover image for Multi-tenancy in .NET 10: isolating tenant data with EF Core named query filters
abdel hakim khammassi
abdel hakim khammassi

Posted on

Multi-tenancy in .NET 10: isolating tenant data with EF Core named query filters

In a B2B SaaS, one customer seeing another customer's data is the bug that ends the contract. Most leaks are not clever attacks: a developer writes a new query and forgets one Where(x => x.TenantId == tenantId).

This article shows how to make that mistake impossible by default in .NET 10. The application resolves the current workspace once per request, EF Core adds the tenant condition to every query, and SaveChanges refuses any write that crosses tenants. The code comes from a .NET 10 boilerplate on ASP.NET Core and PostgreSQL that I maintain.

Choose the isolation model first

For most early-stage B2B products, shared tables with a TenantId column give the best balance of cost and safety, provided isolation is enforced in one place. The three classic models compare like this:

Model Isolation Cost per tenant Migrations Fits
Database per tenant Strongest: separate databases High One run per database Regulated or very large customers
Schema per tenant Strong: separate schemas Medium One run per schema Tens of tenants, some customisation
Shared tables + TenantId Enforced by the application Lowest One run for all Hundreds or thousands of small tenants

The weakness of the shared model is obvious: isolation depends on every query. The rest of this article removes that dependency, so a forgotten filter can no longer leak data.

Resolve the tenant once per request

A middleware decides which workspace the request belongs to, checks the user's membership in the database, and stores the result in a scoped service. Everything downstream reads that service; nothing reads headers or cookies again.

The workspace is chosen in this order:

  1. The X-Tenant-Id header, for API clients and mobile apps.
  2. A saas_tenant cookie, for the browser, set when the user switches workspace.
  3. Otherwise, the user's oldest membership, so a first visit just works.

The membership check is what makes this safe: a forged header or cookie points to a workspace the user does not belong to, and the request simply gets no tenant.

public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, TenantContext tenant, TenancyDbContext db)
    {
        if (context.User.Identity?.IsAuthenticated == true
            && Guid.TryParse(context.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
        {
            var requested = ReadRequestedTenant(context); // header first, then cookie

            var membership = await db.Memberships
                .Where(m => m.UserId == userId && (requested == null || m.TenantId == requested))
                .OrderBy(m => m.JoinedOnUtc)
                .Select(m => new { m.TenantId, m.Tenant.Name, m.Role })
                .FirstOrDefaultAsync(context.RequestAborted);

            if (membership is not null)
            {
                tenant.Set(membership.TenantId, membership.Name, membership.Role);
            }
        }

        await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it between UseAuthentication() and UseAuthorization(), so authorization policies such as "must be an Admin of the current workspace" can read the resolved role.

The scoped TenantContext is exposed to the data layer through a small interface:

public interface ITenantProvider
{
    Guid? TenantId { get; }

    // True for background work (no HTTP request), which may write any tenant's rows.
    // False during a request: without a workspace, tenant-scoped writes are refused.
    bool IsSystemContext { get; }
}
Enter fullscreen mode Exit fullscreen mode

Filter every query with a named query filter

An entity opts in to isolation by implementing one interface; a base DbContext then adds the tenant condition to every query on that entity, automatically. Developers write db.Projects.ToListAsync() and only ever get the current workspace's rows.

public interface ITenantScoped
{
    Guid TenantId { get; }
}

public sealed class Project : AggregateRoot<Guid>, ITenantScoped
{
    public Guid TenantId { get; private set; }
    public string Name { get; private set; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

The base context finds every tenant-scoped root entity and gives it a filter named Tenant:

public abstract class ModuleDbContext(DbContextOptions options, ITenantProvider? tenantProvider = null)
    : DbContext(options)
{
    public const string TenantFilterName = "Tenant";

    private static readonly MethodInfo ApplyTenantFilterMethod =
        typeof(ModuleDbContext).GetMethod(nameof(ApplyTenantFilter), BindingFlags.NonPublic | BindingFlags.Instance)!;

    // Read at each query execution: EF Core turns it into a SQL parameter.
    protected Guid? CurrentTenantId => tenantProvider?.TenantId;

    // Each module configures its own entities here.
    protected abstract void ConfigureModule(ModelBuilder modelBuilder);

    protected sealed override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ConfigureModule(modelBuilder);

        foreach (var type in modelBuilder.Model.GetEntityTypes()
                     .Where(e => e.BaseType is null && !e.IsOwned())
                     .Select(e => e.ClrType)
                     .Where(t => typeof(ITenantScoped).IsAssignableFrom(t)))
        {
            ApplyTenantFilterMethod.MakeGenericMethod(type).Invoke(this, [modelBuilder]);
            modelBuilder.Entity(type).HasIndex(nameof(ITenantScoped.TenantId));
        }
    }

    private void ApplyTenantFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class =>
        modelBuilder.Entity<TEntity>().HasQueryFilter(
            TenantFilterName,
            e => EF.Property<Guid>(e, nameof(ITenantScoped.TenantId)) == CurrentTenantId);
}
Enter fullscreen mode Exit fullscreen mode

Three details matter:

  • CurrentTenantId is a property of the context, not a captured value. EF Core parameterises it, so the same compiled model serves every tenant and every request gets its own value.
  • When no workspace is resolved, the filter compares with null and returns nothing. A request without a tenant fails closed, not open.
  • The filter has a name. EF Core 10 supports several named filters per entity, so a soft-delete filter can sit next to it, and code can lift one filter without the other: IgnoreQueryFilters(["SoftDelete"]) still keeps tenant isolation.

The filter goes on root entity types only: EF Core applies a filter to a whole inheritance hierarchy from its root. Each tenant-scoped table also gets an index on TenantId, since every query now starts with that condition.

Block cross-tenant writes in SaveChanges

Query filters protect reads, not writes: an entity loaded with IgnoreQueryFilters() or attached by hand can still be updated. A second check in SaveChanges refuses to insert, update or delete a row that belongs to another workspace.

private void GuardTenantWrites()
{
    // Background jobs (no HTTP request) are trusted system code.
    if (tenantProvider is null || (CurrentTenantId is null && tenantProvider.IsSystemContext))
    {
        return;
    }

    var tenantId = CurrentTenantId;
    var foreign = ChangeTracker.Entries<ITenantScoped>()
        .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
        .FirstOrDefault(e => tenantId is null || e.Entity.TenantId != tenantId);

    if (foreign is not null)
    {
        throw new InvalidOperationException(
            $"Cross-tenant write blocked on {foreign.Entity.GetType().Name}.");
    }
}

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    GuardTenantWrites();
    return base.SaveChangesAsync(cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

Override the synchronous SaveChanges() the same way, so no code path skips the guard.

The guard throws instead of filtering silently: a cross-tenant write is always a bug, and it should surface in logs and tests, not disappear.

The system-context exception deserves care. IsSystemContext is true only when there is no HTTP request, such as an outbox processor or a scheduled job. Inside a request, a missing workspace blocks every tenant-scoped write, which keeps the rule fail-closed.

Five pitfalls to close

The filter and the guard cover ordinary EF Core code. These five paths go around them, so each needs a rule of its own:

Pitfall What goes wrong Rule
IgnoreQueryFilters() Removes every filter, the tenant one included Name your filters and lift only the one you mean: IgnoreQueryFilters(["SoftDelete"])
Raw SQL (FromSql, ExecuteSql, Dapper) No query filter applies to SQL you write yourself Always pass the tenant id as a parameter; review raw SQL like security code
Caching A cache key without the tenant serves one customer's data to another Put the tenant id in every cache key, or cache only tenant-independent data
Interactive Blazor components They run on a SignalR circuit, outside the HTTP pipeline that resolved the tenant Load tenant data during the first, static render, or pass the workspace id explicitly
Background jobs They have no workspace, so filtered queries return nothing Loop over tenants explicitly and set the workspace per iteration

Finally, test the boundary itself: create two workspaces, write data in one, and assert that the other sees nothing and cannot update it. Add an architecture test that every entity with a TenantId property implements ITenantScoped, so a new entity cannot skip the mechanism.

Conclusion

Tenant isolation should not depend on developers remembering a Where clause. Resolve the workspace once, let a named query filter add it to every read, and let SaveChanges refuse every write that crosses the line: a forgotten filter then returns nothing instead of leaking data.

How do you handle it in your own apps: EF Core filters, PostgreSQL row-level security, or one database per tenant? I'd like to hear what held up in production.


This is one module of the PRYZMA SaaS Starter, a .NET 10 boilerplate with workspaces and roles, invitations, accounts with two-factor authentication, Stripe billing on your own account and Azure deployment. Disclosure: I built it and sell it. If your team already runs a .NET application and wants an outside view of its architecture, I also do a fixed-price architecture audit.

Top comments (0)