SF

Multi-tenant · EF Core · JWT

Multi-tenant isolation: isolating without slowing the API

How to guarantee no query can cross a tenant boundary, without adding a filter to every single call.

5
aggregates covered
3
RBAC roles
0
manual filters per query

The problem

Guarantee that no query can cross a tenant boundary — without that guarantee resting on every developer remembering to add a filter to every call.

The approach

The current tenant is resolved from a JWT claim at authentication, then fed into an EF Core Global Query Filter configured once at the DbContext level. Every LINQ query, even one written without thinking about tenancy, is automatically scoped to the active tenant. On top of that, entity-level RBAC distinguishes Admin, Manager and Collaborator for permissions that don't depend on the tenant.

Code excerptApplicationDbContext.cs
// Tenant isolation is enforced once, in the model — not in every query.
// A developer who forgets a WHERE clause cannot leak another tenant's rows,
// because the filter is compiled into every LINQ query EF Core generates.
protected override void OnModelCreating(ModelBuilder builder)
{
    foreach (var entity in builder.Model.GetEntityTypes()
                 .Where(e => typeof(ITenantScoped).IsAssignableFrom(e.ClrType)))
    {
        builder.Entity(entity.ClrType)
               .HasQueryFilter(BuildTenantFilter(entity.ClrType));

        // Composite index: every filtered query starts with TenantId,
        // so it must lead the index or the filter costs a scan.
        builder.Entity(entity.ClrType)
               .HasIndex(nameof(ITenantScoped.TenantId), "Id");
    }
}

// TenantId comes from the validated JWT, never from the request body.
private LambdaExpression BuildTenantFilter(Type clrType)
{
    var parameter = Expression.Parameter(clrType, "e");
    var property = Expression.Property(parameter, nameof(ITenantScoped.TenantId));
    var current = Expression.Property(
        Expression.Constant(_tenantContext), nameof(ITenantContext.TenantId));

    return Expression.Lambda(Expression.Equal(property, current), parameter);
}

The result

Isolation becomes a property of the framework, not a convention to remember. A new endpoint inherits the filter without writing anything tenant-specific — the most common multi-tenant bug, a forgotten filter on one isolated query, disappears structurally.

PostgreSQL · SignalR · TestcontainersOptimistic concurrency: the conflict you never seeASP.NET Core Identity · JWT · SecurityThe session you thought was closed