CRMSaaSSeptember 2026 · 5 min read

Multi-Tenant CRM Architecture in ASP.NET Core

By go2ismail · Published · .NET 10

At a glance

A Tenant row and a TenantUser membership table connect users to organizations. After login the user confirms a tenant, the token carries it as a claim, reads are filtered by a global query filter, and writes stamp and guard the TenantId.

Read the tenant model

The Blazor CRM Multi-Tenant application, the SaaS edition behind this product page, adds a tenant dimension to the same CRM codebase. Its multi-tenancy works through an IMultiTenant marker interface that adds a single TenantId string property to business entities. In the single-tenant edition the same entities have no TenantId; in the multi-tenant edition the property is the first business property on each entity class, and the EF configuration and query pipeline use it consistently.

Tenant itself is the top-level table: it stores name, description, address, contact fields, website, and an IsActive flag, and it does not implement the tenant interface. It is not filtered by tenant, because it is the root of the model.

Define membership with TenantUser

TenantUser is the membership and join table between an application user and a tenant. It stores TenantId, UserId, a Summary, and an active flag. The configuration maps it to Tenant with a cascade delete and creates a unique index on the pair (TenantId, UserId) so the same user cannot be added twice to the same organization. The Identity user record itself carries no tenant; membership always flows through TenantUser rows.

At registration the signup handler creates a default tenant named “Default Tenant”, assigns the Member and Tenant Admin roles, and inserts the TenantUser row, which is the automatic onboarding behavior present in the inspected code. The seeder similarly creates a demo tenant and maps the administrator into it.

Select an organization after login

After a successful login the SaaS edition routes to an organization selection page at /account/tenant-selection, unlike the single-tenant edition which goes straight to the home dashboard. The page lists the tenants the current user belongs to by querying active TenantUser rows for that user, then joins the tenant name. Selecting an organization posts to the confirm-tenant endpoint.

Access is checked against the same membership table: if the user has no active TenantUser row for the requested tenant, the endpoint returns an access-denied result. There is also a “Change Tenant” menu item in the shell that returns to this page for switching.

Carry the tenant in the token

Confirming a tenant re-issues the JWT with the tenant identifier as a claim and writes it to the token cookie. The claim type used is the standard group identifier claim, and a claim-extension helper reads it back as the current TenantId.

The identifier then travels through every layer. On the Blazor Server circuit, the base page copies the authenticated user’s claims into a scoped current-user state. Each typed service adds the bearer token and an X-TenantId header to its RestSharp requests. Server-side, an endpoint filter on the /api group fills the scoped current-user service from the claims, falling back to the forwarded headers. The DbContext reads that service for its CurrentTenantId.

Scope reads with a global filter

In OnModelCreating, the context builds EF query filters generically for every entity type that implements the tenant interface, combining the soft-delete rule with a tenant equality check:

// concept: every query on a tenant entity is filtered to
// e.TenantId == CurrentTenantId  (and e.IsDeleted == false)

The filter is built as a property access on the current DbContext instance, so it reflects the tenant selected for that request. Because Tenant and TenantUser do not implement the interface, they are exempt, and queries that must read membership rows explicitly ignore the query filter.

If no tenant has been selected, CurrentTenantId falls back to a sentinel value that matches no row, so list queries simply return empty instead of leaking another organization’s data.

Stamp and guard writes

Writes are controlled in the overridden save method. When a row is added, the context stamps the current TenantId. When a row is modified, the context compares the entity’s tenant value with the value originally loaded and with the current tenant, and throws an invalid-operation exception if a cross-tenant change is attempted. If the save path runs without a selected tenant, the context throws rather than writing unowned data.

This is the important distinction to test in your own tenant work: reads degrade to empty results when no tenant is active, while writes fail loudly, and both behaviors come from the DbContext rather than from every individual handler.

Isolate document numbers per tenant

Document numbering is tenant-scoped too. The auto-number sequence table implements the tenant interface, and the generator keys each sequence on the tenant together with the entity name and the year and month. The auto-number configuration files change the unique indexes from a single AutoNumber column to a composite (TenantId, AutoNumber) index, so two tenants can each have document numbers that restart independently.

Know the tenant boundaries

The inspected SaaS edition contains tenant administration and per-tenant data, but it does not include subscription, billing, payment, or plan-limit code. There are no per-tenant roles or per-tenant permission policies; roles are global application roles, and the tenant administration area is gated in the navigation by the Admin role. A tenant admin works inside the current organization through the query filter rather than through per-tenant authorization tables.

Connect the tenant model to the CRM

The pipeline modules, entities, and UI behave exactly as in the single-tenant CRM while every CRM row is isolated by the tenant filter and the save-time checks. For the CRM endpoint and interface contracts that sit on top of this foundation, read Blazor CRM REST API Design and Building a CRM Interface with Blazor and MudBlazor. The SaaS CRM source-code evaluation walks through what the multi-tenant edition includes and excludes. The Blazor CRM Multi-Tenant edition applies this tenant architecture to the complete CRM pipeline on its product page. See this architecture implemented in a complete Blazor CRM application.