The deal is close. Then the prospect's security team sends a questionnaire, and one line on it reads: "How is our data isolated from other customers' data?" Someone on your team writes "we filter every query by tenant ID", someone else says that is not quite true for the nightly export, and the deal waits while you find out what is true.

The trouble is rarely that a multi-tenant .NET backend has no isolation. It is that nobody has written down which kind it has, what enforces it, and what would have to go wrong for one customer to see another's rows. A reviewer will ask for exactly that. A modest claim you can prove passes more reviews than a bold one you cannot.

This article gives you a four-level ladder of multi-tenant data isolation, with the claim each level can honestly support, and an inventory of the code paths that skip an application-level tenant filter. We ran the code below against SQL Server, so the leaks are ones we watched happen. It is written for the CTO or lead engineer who has to answer the questionnaire and then make the answer true.

Multi-Tenant Data Isolation in .NET: A Four-Level Claim Ladder

Microsoft's guidance describes the underlying options: a shared database, sharding, a dedicated database for each tenant, and dedicated deployments for the tenants who need them (see the Azure Architecture Center guide to multitenant storage and data). Its EF Core multi-tenancy page lists three ways to store tenants: a discriminator column, a database per tenant and a schema per tenant. What neither page gives you is a way to decide what you may say about each. That part is ours: the ladder, and the "can claim / cannot claim" columns, are our own framing for a security review, not a Microsoft framework.

Level What enforces isolation You can claim You cannot claim
1. Shared tables, application filter A tenant ID column plus an EF Core global query filter in your code Every query that goes through our data layer is filtered by tenant, and we test that That the database prevents one tenant reading another's rows
2. Shared tables, database-enforced Level 1 plus row-level security: the database applies the tenant predicate to every statement on that connection The database hides other tenants' rows from the application, including raw SQL, and blocks writes for the wrong tenant That a bug in choosing the tenant is impossible, or that a database administrator cannot see the data
3. Database per tenant Each customer's rows live in their own database; the app picks the connection string Customer data is stored separately, and can be backed up, restored and deleted per customer That the application cannot reach another customer's database, unless credentials are per database as well
4. Dedicated deployment The customer gets their own database and their own application instance, often in their own subscription Nothing in the running system is shared with other customers except what we name That shared operations (your pipeline, your support access, your monitoring) are isolated too

We left out schema per tenant on purpose. The EF Core page says it is not directly supported and not a recommended solution, and Microsoft's storage guidance lists table-based isolation inside one database among the antipatterns because it stops scaling as tenants grow. It buys little over Level 1 for a lot of migration work.

Two things about the table are worth reading twice. First, the "cannot claim" column is where reviews go wrong: teams describe the level they intend to run, not the level they can show. Second, Levels 1 to 3 all trust the same thing, which is the answer to "which tenant is this request for?". If that answer is taken from a client-supplied header or route value instead of the signed-in user's validated claims, every level leaks, however good the database is. That is a separate control, and the reviewer will ask about it separately.

The Paths That Skip the Filter

Level 1 is where most teams start, and the EF Core documentation presents the global query filter as the way to do it: a tenant ID column and a filter that removes the need to remember the tenant in each query. The global query filters page also says what a filter is: something added to queries on the entity type, which you can switch off per query with IgnoreQueryFilters. It follows that anything that is not a query on the entity type, or that switches the filter off, is outside the promise. Here is the tempting version:

// NAIVE: the filter is a convention, not a control.
public sealed class NaiveDb(DbContextOptions<NaiveDb> options, NaiveTenant tenant) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();

    protected override void OnModelCreating(ModelBuilder b) =>
        b.Entity<Invoice>().HasQueryFilter(i => i.TenantId == tenant.Id);   // Id is Guid?; null when there is no request
}

We ran it on SQL Server (LocalDB) with .NET 10 and EF Core 10.0.12, with two tenants: A with invoices A-1 and A-2, and B with invoice B-1. Signed in as tenant A:

What tenant A's code did Application filter only Filter plus row-level security
Ordinary LINQ query A-1, A-2 A-1, A-2
IgnoreQueryFilters() A-1, A-2, B-1 A-1, A-2
Database.SqlQueryRaw (raw SQL) A-1, A-2, B-1 A-1, A-2
FromSqlRaw composed with LINQ A-1, A-2 not run
Insert an invoice carrying tenant B's ID Accepted Blocked by the database
Background job with no tenant, counting rows 0 rows, no error 0 rows, even with IgnoreQueryFilters()

Four things stand out. The filter is a read control: it does nothing about a write that carries the wrong tenant. Raw SQL that does not start from a mapped entity, such as SqlQueryRaw, is not filtered at all (the same should hold for Dapper, plain ADO.NET and stored procedures, which we did not run). A raw query composed with LINQ on the entity kept the filter, so "we use raw SQL" is not automatically a leak, but you need to know which kind each call site is. And the background job is the quiet one: with no request there is no tenant, the filter matches nothing, and the job returns zero rows without complaint. The usual next step is a developer who sees the empty result and adds IgnoreQueryFilters() to make the job work, which turns a silent failure into a cross-tenant read.

Outside the database, the same shape of bug appears wherever a key or path is built without the tenant: cache keys, blob storage paths, search indexes, message payloads that carry no tenant ID, and log lines. No database level protects those. Add them to your inventory, because a reviewer who has read the database answer will ask about them next.

Close the Application Leaks First

Before reaching for the database, make the application fail closed. Three changes, all ours rather than a Microsoft recommendation: a missing tenant is an error, not an empty result; every write is checked against the current tenant; and background work runs once per tenant, in its own scope.

/// <summary>The tenant for the current request or job. Set once; never defaults.</summary>
public interface ITenantContext
{
    Guid? Current { get; }
    Guid Require();   // throws when no tenant is in scope
}

public sealed class TenantContext : ITenantContext
{
    private Guid? _id;
    public Guid? Current => _id;

    public void Set(Guid id)
    {
        if (_id is { } existing && existing != id)
            throw new InvalidOperationException("Tenant already set for this scope.");
        _id = id;
    }

    public Guid Require() =>
        _id ?? throw new InvalidOperationException("No tenant in scope. Refusing to query.");
}

Register it as scoped, and set it from the authenticated user's validated claims in middleware, never from a header the caller controls. Then the context:

public sealed class AppDb(DbContextOptions<AppDb> options, ITenantContext tenant) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();

    // Evaluated per query: no tenant in scope throws instead of returning nothing.
    private Guid CurrentTenant => tenant.Require();

    protected override void OnModelCreating(ModelBuilder b) =>
        b.Entity<Invoice>().HasQueryFilter(i => i.TenantId == CurrentTenant);

    public override Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        ChangeTracker.DetectChanges();
        var current = CurrentTenant;

        foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
        {
            if (entry.State == EntityState.Added && entry.Entity.TenantId == Guid.Empty)
                entry.Entity.TenantId = current;                       // stamp, never trust the caller

            if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted
                && entry.Entity.TenantId != current)
                throw new InvalidOperationException(
                    $"Blocked write: {entry.Metadata.ClrType.Name} belongs to another tenant.");

            if (entry.State == EntityState.Modified
                && entry.Property(nameof(ITenantOwned.TenantId)).IsModified)
                throw new InvalidOperationException("TenantId is immutable.");
        }
        return base.SaveChangesAsync(ct);
    }
}

Run against the same data, a context with no tenant threw InvalidOperationException: No tenant in scope. Refusing to query. instead of returning zero rows; tenant A's attempt to save an invoice for tenant B was rejected in SaveChangesAsync; and an invoice saved without a tenant ID was stamped with A's. The filter needs the context to read the tenant at query time. Microsoft's filter page shows a tenant ID field passed to the context in the same way.

For jobs, do not give a job a way to "see everything". Give it a loop over tenants that sets the tenant for each iteration, so that a failure in one tenant's run is logged and does not stop the others:

public static class TenantJobRunner
{
    public static async Task<IReadOnlyList<(Guid Tenant, T Result)>> ForEachTenantAsync<T>(
        IEnumerable<Guid> tenants, Func<Guid, CancellationToken, Task<T>> work,
        ILogger logger, CancellationToken ct)
    {
        var results = new List<(Guid, T)>();
        foreach (var tenant in tenants)
        {
            ct.ThrowIfCancellationRequested();
            try { results.Add((tenant, await work(tenant, ct))); }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Job failed for tenant {TenantId}", tenant);
            }
        }
        return results;
    }
}

The work delegate creates its own DI scope and sets TenantContext to the tenant it was given. The list of tenants comes from the one table that is meant to be cross-tenant, and access to it should be as narrow as you can make it.

Add the Database Backstop: Row-Level Security

Application code can still be bypassed by the next developer who writes a raw query. Level 2 moves the rule to where the data lives. SQL Server, Azure SQL Database and Azure SQL Managed Instance support row-level security, and Microsoft's own multitenant example works the way we need: the application sets the current tenant in SESSION_CONTEXT after connecting, and a security policy with a filter predicate and a block predicate does the rest. Filter predicates hide rows from reads, updates and deletes. Block predicates reject writes that violate the predicate. The policy below is our adaptation of that pattern to a tenant GUID:

CREATE FUNCTION Security.TenantPredicate(@TenantId uniqueidentifier)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS Allowed
       WHERE @TenantId = TRY_CAST(SESSION_CONTEXT(N'TenantId') AS uniqueidentifier);

CREATE SECURITY POLICY Security.TenantIsolation
  ADD FILTER PREDICATE Security.TenantPredicate(TenantId) ON dbo.Invoices,
  ADD BLOCK PREDICATE  Security.TenantPredicate(TenantId) ON dbo.Invoices AFTER INSERT,
  ADD BLOCK PREDICATE  Security.TenantPredicate(TenantId) ON dbo.Invoices AFTER UPDATE
  WITH (STATE = ON);

If SESSION_CONTEXT holds no tenant, the predicate is never true and every row is hidden, which is the fail-closed behaviour you want. Our run confirmed it: a session with no tenant saw zero rows even with IgnoreQueryFilters(). The application side is a connection interceptor that sets the value each time EF opens a connection:

public sealed class TenantSessionInterceptor(ITenantContext tenant) : DbConnectionInterceptor
{
    public override async Task ConnectionOpenedAsync(
        DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct = default)
    {
        if (tenant.Current is not { } id) return;   // no tenant: the predicate sees NULL, every row is hidden

        await using var cmd = connection.CreateCommand();
        cmd.CommandText = "EXEC sp_set_session_context @key = N'TenantId', @value = @v, @read_only = 1";
        var p = cmd.CreateParameter();
        p.ParameterName = "@v"; p.Value = id.ToString();
        cmd.Parameters.Add(p);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

Register it with AddInterceptors on the context options. The @read_only flag matters: Microsoft's example uses it to stop the value being changed again until the connection is closed and returned to the pool, so code that runs later on the same connection cannot switch tenants. Pooling was our worry, so we tested it: three contexts in a row for tenants A, B and A, on pooled connections, each saw only their own invoices.

What Row-Level Security Does Not Give You

Level 2 is a real step up and it has real costs. Microsoft's storage guidance notes that with row-level security the user and tenant identity must travel from the application to the data store on every query, that this is hard to design, implement, test and maintain, and that many multitenant solutions skip it for that reason. Our reading of the RLS documentation adds four more limits:

  • It enforces the tenant you give it. If the application resolves the wrong tenant, the database will faithfully show that tenant's data. This is why the tenant must come from validated claims.
  • Who can change the policy matters. The documentation says the policy applies to every user, including dbo, but that dbo can alter or drop it (the change can be audited). The application's runtime login should not hold ALTER ANY SECURITY POLICY, and migrations should run under a different login. Write down who does hold it: a reviewer will ask.
  • It is not a defence against arbitrary SQL. The documentation warns that carefully crafted queries can infer hidden values through errors. If an attacker can inject SQL, RLS reduces the damage and is not a substitute for fixing the injection.
  • Some features do not combine with it. The same page lists caveats, for example that indexed views cannot be created on tables with a security policy and that Change Data Capture can expose full rows to some roles. Check the list against your design.

The predicate also runs on every statement. Microsoft advises avoiding excessive joins and recursion in predicate functions for performance. Keep ours to a single equality on an indexed column, and measure before you promise anything about latency.

When a Customer Needs Their Own Database

Levels 3 and 4 are for requirements that shared tables cannot meet. Microsoft's storage guidance names three: tenants that need their own encryption keys, tenants with individual backup and restore policies, and tenants whose data must sit in a specific geography. It also notes the cost side: a dedicated database per tenant costs more than a shared one, and Azure SQL Database elastic pools are one way to share that cost. Restoring one customer from a shared database means restoring to a separate resource and recovering that tenant's rows, whereas dedicated databases allow more granular recovery.

In EF Core, database per tenant is a matter of choosing the connection string, and the EF page says the context factory should be transient rather than scoped if a user can switch tenants inside one session. The catch is on the claim side. If the application's credentials can open every tenant database, a wrong connection string leaks exactly as a wrong tenant ID does at Level 2. The stronger version uses a separate credential per database, which is more secrets to manage. Two operational costs also arrive with Level 3: every schema change has to be rolled out to every database, and any report across customers has to read many databases. Microsoft's guidance says to automate the schema rollout from the start, and suggests a central data warehouse for cross-tenant reporting.

Our rule of thumb, which is a judgement and not a standard: use Level 2 for many small tenants on a shared schema, Level 3 for a few large tenants with contractual demands, and Level 4 only when the contract asks for nothing shared. Microsoft's guidance allows mixing: a shared database for most tenants and dedicated deployments for the ones that pay for them or have unusual requirements.

What to Hand the Reviewer

A questionnaire answer is only as good as the evidence behind it. We suggest a one-page pack, which is our own checklist:

  1. The level, in one sentence, and what enforces it.
  2. The bypass inventory: every place that skips the tenant filter (jobs, admin tools, raw SQL, reports, cache, storage, search), and what guards each.
  3. The two-tenant test. Seed two tenants. Run every read endpoint and every job as tenant A. Fail the build if any of tenant B's identifiers appear in a response, a log line, a cache key or a file path. Try one write with B's ID.
  4. Where the tenant comes from, and proof it is derived from the signed-in user's validated claims.
  5. Who can change the controls: who can drop the policy, who can use IgnoreQueryFilters in code review, and who has support access to production data, with how that access is logged.

Support access deserves its own line. An "impersonate tenant" feature is a legitimate cross-tenant path, so it should be explicit, time-limited and written to an audit log, and our article on an immutable audit trail on .NET and Azure covers how to make that log trustworthy.

What This Approach Doesn't Solve

  • Isolation is not certification. A well-enforced level helps with a questionnaire. It does not stand in for a SOC 2 report, an ISO 27001 certificate or a legal review of a customer's regulatory duties. Take those questions to counsel.
  • Shared resources are still shared. Microsoft's guidance warns that a shared database exposes tenants to each other's load (the noisy-neighbour problem). Isolation of data says nothing about isolation of performance.
  • The inventory can go stale. A new job or a new report can add a bypass. The two-tenant test only protects you if new code paths are added to it.
  • We tested the mechanisms, not your system. Our run used one table and two tenants on SQL Server LocalDB. It shows what each mechanism does. It is not a security assessment of a real backend, and other databases have their own row-level security behaviour. Microsoft's guidance says PostgreSQL has it too, and we did not test that.

One condition where we would take a different route: a product with a handful of customers whose contracts already demand separate databases. There, Level 3 from the start is simpler than building Level 2 and then migrating, and a shared-table design would be the extra work.

Common Questions

Frequently Asked Questions

How do I answer "how is our data isolated?" on a customer security questionnaire?
State which of four levels you run, what enforces it, and what you have tested. The levels are: shared tables filtered in application code, shared tables with database-enforced row-level security, a separate database per customer, and a dedicated deployment per customer. Claim only what the enforcement point can back. An application filter supports the statement that every query through your data layer is filtered by tenant. It does not support the statement that the database prevents cross-tenant access. Attach the results of a two-tenant test that tries each path that can bypass the filter.
Is a global query filter enough for tenant isolation in EF Core?
It is a good first layer and not a complete one. The filter applies to queries on the entity type. IgnoreQueryFilters switches it off, it does not apply to queries that bypass the model such as SqlQuery, Dapper or stored procedures, and it does not stop a write that carries another tenant's ID. In our test run on SQL Server, IgnoreQueryFilters, SqlQueryRaw and a write carrying another tenant's ID all got through an application-only filter and were stopped once row-level security was on. We did not run Dapper or stored procedures.
Does SQL Server row-level security work with EF Core and connection pooling?
It can. The pattern in Microsoft's documentation is for the application to set the tenant in SESSION_CONTEXT after opening each connection, and an EF Core connection interceptor can do that on every open. Setting the value read-only stops later code on the same connection from changing it. In our test, three sequential contexts for tenants A, B and A on pooled connections each saw only their own rows.
When should each customer get its own database?
When a contract or a customer's regulator asks for something a shared database cannot give: their own encryption keys, their own backup and restore policy, or their own data location. Microsoft's multitenant guidance names those conditions. Without one of them, our judgement is that the cost of running and migrating many databases usually outweighs the extra isolation. Elastic pools are how Azure SQL Database shares the hosting cost.
What are the usual ways tenant isolation leaks in a .NET application?
The paths that skip the filter: background jobs and message handlers that run without a request, IgnoreQueryFilters used to make an empty result go away, raw SQL and micro-ORM queries, and writes that carry the wrong tenant ID. Outside the database, caches, blob paths and search indexes are keyed without a tenant. Make a missing tenant an error, run jobs once per tenant in their own scope, and test each path with two tenants.

When to Bring in External Help

You can build the two-tenant test and the bypass inventory yourself; both are a few days of a senior engineer who knows the codebase. Outside help pays off when a deal is waiting on the questionnaire and nobody has time to audit every job and report, when the backend grew without a tenant model and the tenant ID is missing from half the tables, or when a customer's contract is pushing you towards a database per tenant and you need to know what that migration costs.

Our Discovery Sprint is a two-week diagnostic that finds the bypass paths, states the isolation level you can honestly claim today, and ranks the work to reach the level your customers are asking for. It follows the same evidence-first method as the backend architecture review we describe elsewhere, with the security boundary as its first area.

Free Resource Not sure where your backend stands before a security review? Start with our free 12-Point Backend Health Checklist. It takes an hour and shows which risks a reviewer is likely to find first.
Back to Insights