← All articles

One Database, Many Customers, No Leaks

Row-level multi-tenancy in ASP.NET Core, as seven requirements

One customer must never see another's rows, in any list, count or endpoint, including the one added next year. Seven requirements that make that a property of the system, and which of them no library can meet for you.
One Database, Many Customers, No Leaks

Multi-tenancy sounds architectural and turns out to be mostly about not forgetting a WHERE clause. One customer's data must never appear in another customer's screens: not in any list, count, include or endpoint, and not in the endpoint someone adds two years from now.

There are three classic isolation levels: a database per tenant, a schema per tenant, and row-level isolation, where every tenant-owned row in one shared schema carries a TenantId. Row-level is the cheapest to operate and the easiest to get subtly wrong, because its correctness lives in query predicates rather than in infrastructure. If developers must remember to filter, someone eventually won't.

Every row-level implementation has to meet the same requirements, and the library you choose decides how many it meets for you and how many it leaves to discipline. This article is the checklist, whichever library enforces the WHERE clause:

  1. Tenant-owned entities are marked once, and nothing else is required of them.
  2. Every read is scoped — lists, counts, includes, details, and direct service calls from your own code.
  3. Every write is stamped from the caller's credential; the payload's tenant id is ignored.
  4. The tenant id lives in the credential, and switching tenants re-issues it.
  5. The tenant store itself is unscoped and lives apart from tenant data.
  6. Jobs and raw SQL have an explicit, per-case answer to "which tenant?" — and no tenant means an empty view, never an unscoped one.
  7. A test proves that a foreign tenant's read returns nothing, for each key type you use.

The worked example is Regira Entities, which meets the first three with a marker interface, one global query filter and one save-time primer, and the open-source Regira-Fleet API (live demo), which shows the list in production shape.

Universal or Regira? Requirements one to three are a design you can build on plain EF Core with HasQueryFilter and a save interceptor; the registration that makes them apply to every entity without opting in is Regira Entities. Requirements four to seven are library-independent, and they are the half most write-ups leave out.

The marker and the tenant context

public interface IHasTenantId { string TenantId { get; set; } }   // string: plays nice with Identity/GUID keys

public interface ITenantContext { string? TenantId { get; } }

ITenantContext answers one question — whose request is this? — and the HTTP implementation reads it from the authenticated principal, lazily, on every access:

// register AddHttpContextAccessor()!
public class TenantContext(IHttpContextAccessor httpContextAccessor) : ITenantContext
{
    public string? TenantId => httpContextAccessor.HttpContext?.User.FindFirstValue("tenant");
}

The tenant travels inside the caller's credential, as a claim: not in a header, a route segment or a query parameter, for reasons that get their own section below. Hosts without an HTTP request — seeders, background jobs, consoles — get a writable stand-in instead:

public class WritableTenantContext : ITenantContext { public string? TenantId { get; set; } }

Domain entities implement the marker, typically through a shared app-level base interface, so that adding an entity without deciding is impossible:

public interface IAppEntity : IEntity<int>, IHasTimestamps, IHasTenantId { }

public class Vehicle : IAppEntity /* … */
{
    public int Id { get; set; }
    [StringLength(32)] public string TenantId { get; set; } = null!;
    // ...
}

Reads: one global filter for every tenant-owned entity

Regira Entities builds every query through a pipeline of query builders, and a global filter builder is one registered against an interface instead of an entity, so it runs for every registered entity that implements it (Filters You Register Once and Cannot Forget introduces them). That is the hook:

public class FilterHasTenantQueryBuilder(ITenantContext tenantContext)
    : GlobalFilteredQueryBuilderBase<IHasTenantId, int>
{
    public override IQueryable<IHasTenantId> Build(IQueryable<IHasTenantId> query, ISearchObject<int>? _)
        => query.Where(x => x.TenantId == tenantContext.TenantId);
}

One class, one Where. Because it runs inside the query pipeline rather than in a controller base or in each repository, it scopes every read that flows through an IEntityService: list endpoints, search endpoints with their counts, detail lookups, and direct service calls from your own code. A request authenticated for tenant A that asks for tenant B's vehicle #17 gets a 404, indistinguishable from a row that never existed. From inside a request, other tenants' data is not forbidden; it is not there.

EF Core's HasQueryFilter can play the same role, and reaches further: it also filters Included collections and queries written straight against the DbContext, and since EF Core 10 a named filter can be switched off on its own (IgnoreQueryFilters(["Tenant"])). It is declared per entity type, though, not against an interface, and it reads the tenant through a member of the context, so the context has to carry it. The pipeline filter is one class for every IHasTenantId, taking ITenantContext from DI like any other service.

This filter reads no search-object field, so its int-keyed registration also covers entities with a string or Guid key; the post linked above explains why there is nothing else to register. Still, write the one integration test that proves a foreign-tenant read comes back empty, per key type in use. It is requirement seven, and the cheapest security test you will ever add.

Writes: a primer stamps what the client cannot be trusted to send

Filtering reads is half the contract. The other half: a row must never be written into the wrong tenant, whether by malice or by a buggy SPA sending a stale tenantId. Regira Entities calls its save-time interceptors primers — they run inside EF Core's SaveChanges, and Rules That Hold on Every Save is their introduction — and the tenant primer is as small as the filter:

public class HasTenantPrimer(ITenantContext tenantContext) : EntityPrimerBase<IHasTenantId>
{
    public override Task PrepareAsync(IHasTenantId entity, EntityEntry entry, CancellationToken token = default)
    {
        if (!string.IsNullOrWhiteSpace(tenantContext.TenantId))
            entity.TenantId = tenantContext.TenantId;
        return Task.CompletedTask;
    }
}

Whatever TenantId arrived in the payload is overwritten with the authenticated tenant: not validated, ignored. With the read filter in place, a request carrying tenant A's token cannot see, keep or create rows for tenant B. The IsNullOrWhiteSpace guard is the escape hatch: when no tenant is active — a seeding host with an empty WritableTenantContext — explicitly set ids pass through, which is what cross-tenant seed data needs.

Registration: the whole feature is two lines

services.AddHttpContextAccessor()
        .AddScoped<ITenantContext, TenantContext>();

services.UseEntities<AppDbContext>(options =>
{
    options.UseDefaults();
    options.AddGlobalFilterQueryBuilder<FilterHasTenantQueryBuilder>();  // every read scoped
    options.AddPrimer<HasTenantPrimer>();                                // every write stamped
});

That is the entire wiring; UseDefaults() registers the interceptor that runs primers into the DbContext options. From here on, multi-tenancy is inherited, not implemented: new entity, add IHasTenantId, done. The endpoint added two years from now is scoped before its author has read this article.

One request's path: ITenantContext reads the tenant claim from the credential and feeds the global filter on every read and the primer on every write. Two cases need an answer of their own: a background job, which has no request to take a tenant from and sets one or sees an empty view, and raw SQL or table-valued functions, which take the tenant id as an explicit parameter.

The moving parts come from the Regira.Entities packages: GlobalFilteredQueryBuilderBase and EntityPrimerBase from Regira.Entities/Regira.Entities.EFcore, the UseEntities/AddGlobalFilterQueryBuilder/AddPrimer registration from Regira.Entities.DependencyInjection.

Where the tenant id comes from

The infrastructure above consumes a tenant claim; producing it well is its own small design. At sign-in, a claims step reads the requested tenant (say ?tenantId= on the login call), verifies the user is a member of it, and mints the credential with new Claim("tenant", tenantId).

The consequence people trip over: switching tenants means re-issuing the credential. With JWTs, mint a new token; with cookie sessions, sign in again with the new claim set. There is no "current tenant" state to update server-side, because the credential is the state. That is why TenantContext stays so small: it reads HttpContext.User and does not care which authentication scheme put the claim there.

Two notes for Entra ID: the tenant claim already exists there as tid, and the stable per-user key is oid, not sub — Entra's sub is pairwise per application, so tenant-membership rows keyed on it fragment one person across apps.

The tenant list itself is not tenant-filtered

Easy to state, easy to miss: the tenant list is administrative data. It lives with the identity store (Fleet keeps a separate accounts database for ASP.NET Identity users, tenants and claims, next to the fleet database for domain data), it does not implement IHasTenantId, and the tenant filter is not registered on the identity context at all, or the back-office can no longer list tenants. Scoping is for the domain; administration stands outside it.

Fleet in production shape

Regira-Fleet's registration is the snippet above with the rest of a realistic pipeline in the same builder — Mapster mapping, normalization for search, soft delete:

Services.UseEntities<FleetContextBase>(c =>
{
    c.UseMapsterMapping();
    c.AddNormalizer<IEntityLabel, EntityLabelNormalizer>();
    c.UseDefaults(ed => ed.ConfigureNormalizing(o => o.Transform = TextTransform.ToUpperCase));

    c.AddGlobalFilterQueryBuilder<FilterHasTenantQueryBuilder>();
    c.AddPrimer<HasTenantPrimer>();
    // ArchivablePrimer is registered by UseDefaults()

    // free-text search; on PostgreSQL, Fleet swaps in an ILike variant
    c.AddGlobalFilterQueryBuilder<FilterHasNormalizedContentQueryBuilder>();
});

None of those features know about tenants, and the global filter scopes them anyway: soft delete (ArchivablePrimer plus the archived filter) and normalized free-text search both read through the same pipeline, which is the argument for putting cross-cutting rules at this layer. Registered on an abstract context, the rule rides along unchanged on whichever database provider the concrete context picks.

The full source — three database providers, identity, claims per tenant, seeding — is at github.com/bverboven/RegiraFleet-Backend. One warning before you clone it: running Fleet takes a Regira Entities license key (its README says where it goes); there is no free-tier build of it.

Five places the pipeline does not reach

A mismatch that should be a 400. The primer stamps; it does not verify. The payload's TenantId is overwritten, never compared, so "tenant does not match" is not a validation error you can surface. If your domain wants one, add a validator scoped to IHasTenantId that refuses a payload naming a tenant other than the signed-in one, and leave the primer as it is.

Included children. A pipeline filter is a plain Where on the root query; it does not reach inside Include(...)'d child collections. Give child rows the marker too, or filter in the include lambda. Fleet takes the first route: child rows like InterventionAction implement IFleetEntity just as their parents do, so the marker, the filter and the primer cover them directly.

Attachments. A file's link row (VehicleAttachment) has no tenant column and no navigation back to its vehicle, so the tenant filter never runs on it: without more, every attachment route — the lists, both downloads, renaming and deleting a file — reads across tenants. Give each link entity a second global filter, registered beside the first, that reruns the tenant check through the owner's table: keep a link only when db.Vehicles holds its ObjectId for the current tenant. Another tenant's file then answers 404. An upload is still not scoped: it is a create that takes the owner's id from the route and reads nothing, so no filter sees it, and any signed-in user can attach a file to another tenant's row. Look the owner up through its own filtered service before accepting the file, and refuse when nothing comes back: in an override of the attachment controller's Add, or in a validator on the link entity.

A background job. The filter is an unconditional Where, so a job with no tenant set compares TenantId against null and gets an empty view, not an unscoped one. Fail-closed is the right default for a security filter, but every job then needs an explicit answer. A per-tenant job sets WritableTenantContext.TenantId per unit of work (loop the tenants for an all-tenants sweep); a genuinely cross-tenant maintenance job bypasses the scoped pipeline deliberately, by querying the DbContext directly or by running on a registration without the tenant filter. A conscious choice, not an accident of DI.

Raw SQL. Table-valued functions, FromSqlRaw and hand-written reports do not flow through query builders, so none of them are scoped. Every raw-SQL surface takes TenantId as an explicit parameter — the rule the hierarchy deep dive hit with its recursive-CTE tree functions.

The checklist, ticked

Row-level tenancy is solved when all seven are true. Where each was met above:

  1. Marked once — IHasTenantId, reached through the app-level base interface.
  2. Every read scoped — FilterHasTenantQueryBuilder, one global filter in the pipeline; included children carry the marker themselves. Attachments need a filter of their own on their link rows, and an owner check on every upload.
  3. Every write stamped — HasTenantPrimer, which overwrites whatever the payload sent.
  4. Tenant in the credential — the tenant claim minted at sign-in after a membership check; switching tenants re-issues it.
  5. Tenant store unscoped — the identity/admin context never registers the filter, and never implements the marker.
  6. Jobs and raw SQL decided per case — a WritableTenantContext per job or an explicit unscoped path, TenantId as a parameter of every table-valued function.
  7. A foreign-tenant test per key type — the invariant, not the implementation, is what it guards.

Three small classes meet the first three, attachments aside; the other four are decisions no library takes for you.

Why not Finbuckle.MultiTenant? Finbuckle is the de-facto .NET library and covers ground this article does not: tenant resolution from host, route, header or claim, per-tenant options and authentication, database-per-tenant next to row-level isolation. If you are not already on Regira Entities, use it for requirements one to three; nothing above competes with it. What its documentation leaves to you is the second half of the list, which is the half of this article worth keeping open beside it.

Docs: the multi-tenancy blueprint ships with the Regira Entities docs; the registration packages are commercial with a free tier.

Versions: validators arrived in Regira Entities 6.5.0. Through 6.4, a link's own body could also point it at another owner's file, and an upload could move another owner's link; 6.5.0 keeps every link on the attachment it was created with, refuses a new one naming another owner's, and gives every upload a link of its own.

Built with

Regira packages

This is one of the pieces we build client applications with: entity CRUD over EF Core, Office documents, file storage and more, published on nuget.org.

All Regira packages Package documentation Free tier & licensing