Multi-Tenancy Is an Identity Problem

Silo, pooled or partitioned, the data layout sets the isolation boundaries. Whether the code respects them starts with knowing which tenant an operation belongs to, and where that gets answered.

Teams building a SaaS spend weeks on where a tenant's data lives and let the real question leak into every query: which tenant is this request for? If the answer is a where clause that every developer has to remember, one of them will forget it, and tenant A will read tenant B's orders.

The Options Three ways to keep tenants apart

Model You get You pay
Silo: own compute, own database per tenant A separate process, database and credentials for each tenant Idle servers for tenants who barely log in, one deployment per tenant
Pooled compute, siloed data One app serves everyone, each tenant keeps its database The app picks the right connection string on every request
Partitioned: one database, a tenant id on every row Cheapest to run, one schema to migrate Every query must filter by tenant id, and restoring one tenant usually means restoring a backup separately and recovering its data selectively

Pick the layout by cost, compliance, how much isolation your customers require, and whether you will ever need to restore one tenant alone. The layout sets the boundaries. The rest of this post is about how the code respects them.

The Catch The layout alone does not protect you

Partitioned data fails on one forgotten where. Siloed data fails on one wrong connection string. In both cases the leak is the same mistake: a request reached the data layer without the code knowing, for certain, which tenant it was for.

The database layout defines the isolation boundaries. A trusted tenant context is what makes the application respect them.

The Key The token carries the tenant

A client logs in at the identity service and receives a token with a tenant id. The client calls the API with that token. The API reads the tenant once and the order DbContext filters the shared Orders table by tenant id, returning only that tenant's rows.
The read path. The tenant is checked at login and travels in the token. The data layer reads it, the application code never does.

The user logs in and picks a tenant, because a user can belong to several. The identity service checks that membership before it issues a token, and the token carries the chosen tenant id. Every request carries the token. The API validates it and rejects a tenant-scoped request without a valid tenant claim. From there the tenant is read once, at the edge, and handed to the data layer. Below that line the code behaves as if there were one tenant in the world.


// Read path: every query on Orders is filtered by the tenant of the current request.
public sealed class OrderDbContext : DbContext
{
    private readonly Guid _tenantId;

    public OrderDbContext(DbContextOptions<OrderDbContext> options, ITenantContext tenant)
        : base(options) => _tenantId = tenant.TenantId;

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder model) =>
        model.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenantId);
}

// Program.cs. Authentication validated the token and an authorization policy
// already rejected requests without a tenant claim with a 403. The throw below
// is a guard against misuse (a context resolved outside that policy), not the
// HTTP rejection itself.
builder.Services.AddScoped<ITenantContext>(sp =>
{
    var user = sp.GetRequiredService<IHttpContextAccessor>().HttpContext?.User;
    var claim = user?.FindFirstValue("tenant_id");
    return Guid.TryParse(claim, out var tenantId)
        ? new TenantContext(tenantId)
        : throw new InvalidOperationException("ITenantContext resolved without a validated tenant claim.");
});

    

db.Orders.ToListAsync() now returns one tenant's orders with no filter in the calling code. With siloed data the same ITenantContext picks the connection string instead of the filter, and the application code does not change between the two models. The example shortens the production setup: the HTTP rejection is the job of an authorization policy that runs before any context is created. The factory only reads what the policy already accepted, and its exception is a guard against misuse, not the way a request gets refused.

The Other Half Writes

The filter covers reads. It does not put a tenant id on a new order, and it does not stop code from attaching another tenant's row and saving it. The write path has its own job: assign the tenant to every new entity, and check that every row being changed or deleted belongs to the current tenant. Keep that in the data layer too, not in every handler.

One more limit: IgnoreQueryFilters() switches the filter off with a single call. The filter is a convenience for the code, not a security boundary on its own.

No Request Workers, consumers and jobs

Background processing cannot rely on the originating request's HttpContext or access token. A queue consumer, a worker or a scheduled job gets the tenant from the message or the job definition, and the same ITenantContext is built from it before any code runs. That tenant has to come from a trusted source, or be checked: a tenant id in a payload is not proof of access, the publisher that put it there is. The rule does not change: no operation starts without a trusted tenant. The tenant is also part of every tenant-scoped cache key, because a cache that ignores it serves one tenant another tenant's data over a perfectly correct database.

The Bonus Lanes

Once the token knows the tenant, the login response can also name the host: app1 for one group of tenants, app2 for another, each lane with its own compute and data. Deploy a new version to one lane first and watch it. The URL only routes the client. The backend of each lane still checks that the tenant belongs to it.

The Rule Resolve the tenant once

Tenant isolation should not depend on developers remembering a predicate in every query.

Resolve the tenant once, at the edge, from identity. Let the data layer apply it to reads and writes. Hand it explicitly to everything that runs without a request.


More posts
← All posts  ·  RSS © 2026 DotNET Leet