← All articles

Row-level multi-tenancy in ASP.NET Core: one marker, one filter, one primer

How to make tenant isolation a property of the data layer instead of a discipline problem — with a real multi-tenant fleet-management API as the case study.
Row-level multi-tenancy in ASP.NET Core: one marker, one filter, one primer

Multi-tenancy is one of those requirements that 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 — across every list, every count, every include, every endpoint, forever, including the endpoint an intern adds two years from now.

There are three classic isolation levels: a database per tenant, a schema per tenant, and row-level isolation — one schema, every tenant-owned row carrying 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.

The pattern this article walks through removes the remembering. It's the approach used by Regira Entities and demonstrated end-to-end in Regira-Fleet, an open-source fleet-management API (live demo) — and it consists of exactly three small pieces:

  1. a marker interface on tenant-owned entities;
  2. one global query filter that scopes every read;
  3. one primer (a save interceptor) that stamps every write.

Nothing per-entity. Nothing per-controller. Implementing the interface is all an entity ever does.

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");
}

Note what this is not: it's not a header, not a route segment, not a query parameter. The tenant travels inside the caller's credential as a claim — more on why below. Non-HTTP hosts (seeders, background jobs, consoles) get a writable stand-in instead:

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

Domain entities then implement the marker, typically via a shared app-level base interface so it's impossible to add an entity without deciding:

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 — filters, sorting, paging, includes. Alongside per-entity filters, the pipeline supports global filter builders that apply to every registered entity assignable to some interface. That's 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 — not in a controller base, not in each repository — it scopes every read that flows through an IEntityService: list endpoints, search endpoints with their counts, details lookups, and direct service calls from your own code. A request authenticated for tenant A doesn't get "an error" when it asks for tenant B's vehicle #17 — it gets a 404, indistinguishable from a row that never existed. From inside a request, other tenants' data isn't forbidden; it's not there.

(If you're not on Regira Entities, EF Core's HasQueryFilter can play the same role, with two caveats this design avoids: model-level filters capture the tenant from the context instance — so the context must be scoped and carry the value — and they need IgnoreQueryFilters() escape hatches managed by hand. A pipeline-level filter keeps the mechanism in DI where it can depend on ITenantContext naturally.)

One mechanical detail worth understanding: global filters are registered per key type, and the framework picks the variant whose key type matches each entity. For a key-agnostic predicate like this one — the Where never touches the search object — the framework deliberately applies the registered variant to entities of other key types anyway (coercing the foreign-typed search object to null rather than stepping aside), precisely so a security default can't be silently dropped. Where the per-key variants (FilterHasTenantQueryBuilder<TKey>, a one-liner via the generic base — Fleet declares the pair) do matter is filters that read typed search-object fields. Either way, write the one integration test that proves a foreign-tenant read comes back empty, per key type in use: it's the cheapest security test you'll ever add.

Writes: a primer stamps what the client can't be trusted to send

Filtering reads is half the contract. The other half: a row must never be written into the wrong tenant — not by malice, not by a buggy SPA sending a stale tenantId. Regira Entities calls its save-time interceptors primers (they run as EF Core SaveChanges interceptors), 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. The client's value is not validated — it's ignored. Combined with the read filter, the invariant becomes airtight in both directions: a request carrying tenant A's token cannot see, keep, or create rows for tenant B. And notice the escape hatch hiding in the IsNullOrWhiteSpace guard: when no tenant is active (a seeding host with an empty WritableTenantContext), explicitly-set ids pass through — which is precisely 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's the entire wiring (UseDefaults() auto-wires the interceptor that runs primers into the DbContext options). From here on, multi-tenancy is inherited, not implemented: new entity, add IHasTenantId, done. The intern's endpoint two years from now is scoped before they've read this article.

Where the tenant id actually 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) plus that tenant's permission claims — in Fleet, rows in a TenantUserClaim store (user × tenant × claim) become claims like permissions=can_read, can_write, enforced by globally registered authorization filters.

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's no "current tenant" state to update server-side — the credential is the state, which is why TenantContext gets to stay so small and incurious: it reads HttpContext.User and doesn't care which authentication scheme put the claim there.

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

The Tenant entity is not tenant-filtered

Easy to state, easy to miss: the tenant list itself 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 must not be registered on the identity/admin context at all — or your back-office can no longer list tenants. Scoping is for the domain; administration deliberately stands outside it.

Case study: Fleet in production shape

Regira-Fleet is worth reading as the non-toy version of everything above. It tracks vehicles, interventions, operators and invoices; every domain entity implements IHasTenantId through the shared IFleetEntity interface; an AppContextLoader middleware resolves tenant and culture from the JWT per request. The registration reads almost exactly like the snippet above — Fleet's own FilterHasTenantQueryBuilder differs only in routing the tenant through a composite IFleetAppContext (tenant + culture) instead of a bare ITenantContext, and in declaring the generic <TKey> variant alongside the int alias — plus the rest of a realistic pipeline in the same builder — Mapster mapping, normalization for search, timestamps:

Services.UseEntities<FleetContextBase>(c =>
{
    c.UseMapsterMapping();
    c.UseDefaults(ed => ed.ConfigureNormalizing(o => o.Transform = TextTransform.ToUpperCase));
    c.AddGlobalFilterQueryBuilder<FilterHasTenantQueryBuilder>();
    c.AddPrimer<HasTenantPrimer>();
    c.AddPrimer<ArchivablePrimer>();
});

Two things about the case study repay attention. First, the tenancy plumbing is registered on FleetContextBase — an abstract context — while the concrete contexts are per-provider: the same domain runs unchanged on SQL Server, PostgreSQL or MySQL, chosen by one configuration value, and the tenant filter and primer ride along identically on all three. Row-level tenancy done in the query pipeline is provider-neutral by construction.

Second, tenancy composes quietly with everything else in the pipeline: soft-delete (ArchivablePrimer plus the archived filter), normalized free-text search, attachments. None of those features know about tenants; the global filter scopes them anyway, because they all read through the same pipeline. Composability is the real argument for putting cross-cutting rules at this layer.

The gotchas, honestly

The primer stamps; it does not verify. There's no check that the payload's TenantId matched — it's simply overwritten. That's the right default (the client's value is untrusted input), but it means "verify tenant matches" isn't a validation error you can surface. If a mismatch should be a 400 in your domain, add that check in a prepper; don't weaken the primer.

Background jobs see nothing — decide per job. Global filters compare against the resolved ITenantContext, and the filter shown 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 it means every job 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 must bypass the scoped pipeline deliberately — query the DbContext directly, or host the job on a registration without the tenant filter. Make it a conscious choice, not an accident of DI.

Raw SQL bypasses the pipeline. Table-valued functions, FromSqlRaw, hand-written reports: none of them flow through query builders, so none of them are scoped. Any raw-SQL surface needs TenantId as an explicit parameter — the same rule we hit with the recursive-CTE tree functions in the hierarchy deep dive, where archived-row and tenant rules both had to be re-applied inside the function.

Key-type variants. As covered under Reads: a key-agnostic predicate like this one is applied across key types even when only the int variant is registered — the framework refuses to drop a security default. Still register the <TKey> variants for any filter that reads typed search-object fields, and keep the per-key-type foreign-tenant test regardless: it guards the invariant, not the implementation detail.

The checklist

Row-level tenancy is solved when all of these are true:

  • Tenant-owned entities share a marker interface; nothing else is required of them.
  • Every read is scoped by one globally registered filter — including counts, includes and direct service calls.
  • Every write is stamped from the credential by one interceptor; the payload's tenant id is ignored.
  • The tenant id lives in the credential (claim), and switching tenants re-issues it.
  • The tenant/admin store is unscoped and separate.
  • Jobs and raw SQL have an explicit, per-case answer for "which tenant?" — remembering that no tenant means an empty view, not an unscoped one.
  • A test proves cross-tenant reads return nothing, for each key type.

Three small classes and a habit of registering them — and tenant isolation stops being a code-review checklist item and becomes a property of the system.

For your own build, 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 (commercially licensed with a free tier; running the full Fleet sample takes a license key, noted in its README).

The full sample — three database providers, identity, claims-per-tenant, seeding — is at github.com/Regira/RegiraFleet-Backend; the multi-tenancy blueprint (with the seeding patterns and Entra notes) ships with the Regira Entities docs.