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 Key The token carries the tenant
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
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.