Designing a Multi-Tenant SaaS Platform With ASP.NET Core

A practical engineering guide to tenant isolation, resolution, authorization, and scaling trade-offs for multi-tenant SaaS platforms built on ASP.NET Core.

Consider a platform selling to mid-market companies: 40 customers today, a roadmap that assumes 400 within two years, and a sales team that has already promised three prospects “your data will never touch another customer’s database.” Every one of those constraints — cost at 400 tenants, an isolation guarantee made before the architecture existed, and a team that still needs to ship features weekly — pulls the design in a different direction, and reconciling them is the actual job.

Multi-tenancy is not a feature you bolt onto a single-tenant application later. It is a decision that touches the data layer, the authentication pipeline, background processing, logging, and deployment topology simultaneously. Get it wrong and the failure mode is not a slow page — it’s tenant A reading tenant B’s invoices. This article walks through the decisions that matter, in the order a team actually has to make them, with the trade-offs made explicit rather than glossed over.

Start with the isolation model, because it constrains everything else

The first and most consequential decision is how tenant data is isolated. There are three well-established strategies, and the “right” one depends on tenant count, tenant size variance, compliance requirements, and how much operational maturity the team has.

Database-per-tenant. Each tenant gets its own database (or, on Azure SQL, its own database inside an elastic pool). Isolation is strong — a bug in a query can’t leak across the trust boundary because there is no shared storage to leak across. This is the model regulated customers (healthcare, financial services) often require contractually. The cost is operational: schema migrations must run against every tenant database, connection pooling gets harder as tenant count grows into the hundreds, and cross-tenant reporting (e.g., “show me usage across all tenants”) requires fan-out queries or a separate analytics pipeline.

Shared database, tenant discriminator column. All tenants share one database and one set of tables; every tenant-owned row carries a TenantId column, and every query filters on it. This is by far the cheapest and easiest to operate — one schema, one migration, one connection pool — but it pushes the entire isolation guarantee onto application code being correct, every time, in every query. That is the model’s real risk: it doesn’t fail loudly, it fails as a silent cross-tenant data leak the first time someone writes a LINQ query without the filter.

Schema-per-tenant. A middle ground on PostgreSQL or SQL Server: one database, one schema per tenant, tables replicated per schema. It gives stronger logical isolation than a shared table without the full operational cost of separate databases, but most ORMs (EF Core included) don’t support dynamic schema selection cleanly — you typically end up building it yourself via a custom connection-string or schema-switching interceptor, and migrations still have to run N times.

Shared DB + TenantId column
One Database, One Schema
Orders WHERE TenantId = ?
Invoices WHERE TenantId = ?
Schema-per-tenant
One Database
Schema: tenant_a
Schema: tenant_b
Database-per-tenant
Tenant A DB
Tenant B DB
Tenant C DB
flowchart TB
    subgraph DBPT["Database-per-tenant"]
        A1[Tenant A DB]
        A2[Tenant B DB]
        A3[Tenant C DB]
    end
    subgraph SPT["Schema-per-tenant"]
        B0[(One Database)]
        B1[Schema: tenant_a]
        B2[Schema: tenant_b]
        B0 --- B1
        B0 --- B2
    end
    subgraph SDB["Shared DB + TenantId column"]
        C0[(One Database, One Schema)]
        C1["Orders WHERE TenantId = ?"]
        C2["Invoices WHERE TenantId = ?"]
        C0 --- C1
        C0 --- C2
    end

    style DBPT fill:#e8f4ea,stroke:#4a8f5c
    style SPT fill:#fdf3e0,stroke:#c99a2e
    style SDB fill:#fbeaea,stroke:#c0524f
Dimension Database-per-tenant Shared DB + TenantId Schema-per-tenant
Isolation strength Strongest — physical separation Weakest — depends on correct query filters everywhere Strong — logical separation, shared engine
Blast radius of a bug One tenant Potentially all tenants One tenant’s schema
Cost at scale (100s of tenants) High — per-DB compute/storage floor, connection pool pressure Lowest — one set of resources shared Medium — one DB, but per-schema migration and metadata overhead
Operational complexity High — migrations, backups, monitoring all multiply per tenant Low — single pipeline for everything Medium-high — most tooling assumes one schema; custom tenant-routing needed
Noisy-neighbor risk Low — resource contention is per-database High — one tenant’s heavy query load or table bloat affects everyone Medium — shared compute/IO, but schema-level indexes limit some spillover
Cross-tenant analytics Hard — requires fan-out or a separate warehouse Easy — one query, one filter Moderate — requires querying across schemas
Best fit Regulated industries, large/enterprise tenants, low tenant count High tenant count, cost-sensitive, SMB-focused SaaS Mid-size tenant count wanting stronger isolation than shared tables without full DB sprawl
Illustrative cost/effort Higher infra spend at scale — often several times the monthly compute/storage cost of shared-DB once you’re in the 100–200 tenant range, given per-database floors and backup overhead Lowest infra cost; the isolation layer itself (middleware, query filters, repository guardrails) is typically a few engineer-weeks to build, regardless of model chosen Similar infra cost to shared-DB, but usually more upfront engineering time than either alternative since ORM tooling support is thinner

These are rough, illustrative figures, not a benchmark from a specific deployment — actual numbers vary widely by cloud provider, tenant size distribution, and team experience, but the direction (database-per-tenant costs more in infrastructure; the engineering effort to build proper isolation is roughly comparable across models) tends to hold.

A pattern that works well in practice: start with shared-database for the long tail of small tenants, and give database-per-tenant as a paid tier or contractual requirement for a handful of large/enterprise accounts. This is a hybrid, not a compromise — most SaaS companies that survive to scale end up here anyway, so designing the tenant-resolution and data-access layers to support both from day one saves a painful migration later.

Tenant resolution: where the request learns who it belongs to

Before any of that isolation logic can run, the request needs to know which tenant it’s for. The common signals, roughly in order of how common they are in real systems:

  • Subdomain (acme.yourapp.com) — simple, visible to the user, easy to route with a reverse proxy, but requires wildcard DNS/TLS and doesn’t work well for custom domains.
  • Custom domain (app.acme.com via CNAME) — what enterprise customers usually want, but requires a domain → tenant lookup and per-domain TLS certificate provisioning (Azure Front Door and Let’s Encrypt with SNI both handle this, but it’s real infrastructure work).
  • JWT claim — for API-first products, embed tenant_id in the access token at issuance. This is the most tamper-resistant option because the tenant identity is cryptographically bound to the token rather than inferred from the request.
  • Custom header (X-Tenant-Id) — convenient for service-to-service calls and testing, but must never be trusted from an end-user-facing request without also validating it against the authenticated identity, or you’ve built a one-line tenant-impersonation bug.

The resolution should happen as early as possible in the pipeline — as ASP.NET Core middleware, before MVC model binding or authorization runs — and the resolved tenant should be attached to a scoped ITenantContext that everything downstream (controllers, EF Core, background jobs) reads from. Nothing further down the pipeline should be re-deriving tenant identity from a route parameter or query string; that’s how you get inconsistent enforcement.

// TenantResolutionMiddleware.cs
// Resolves the current tenant as early as possible in the pipeline and
// makes it available via a scoped ITenantContext for the rest of the request.
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;

    public TenantResolutionMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(
        HttpContext context,
        ITenantStore tenantStore,      // cached lookup, not a DB hit per request
        ITenantContextAccessor tenantContextAccessor)
    {
        Tenant? tenant = null;

        // 1. Prefer an already-authenticated JWT claim — it's cryptographically
        //    bound and cannot be spoofed by changing the URL or a header.
        var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
        if (tenantClaim is not null)
        {
            tenant = await tenantStore.GetByIdAsync(tenantClaim);
        }

        // 2. Fall back to subdomain resolution for unauthenticated routes
        //    (e.g. the login page, which needs to know the tenant's SSO config
        //    before a token exists).
        if (tenant is null)
        {
            var host = context.Request.Host.Host; // e.g. "acme.yourapp.com"
            var subdomain = host.Split('.').FirstOrDefault();
            if (!string.IsNullOrEmpty(subdomain))
            {
                tenant = await tenantStore.GetBySubdomainAsync(subdomain);
            }
        }

        if (tenant is null)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            await context.Response.WriteAsync("Unknown tenant.");
            return;
        }

        if (!tenant.IsActive)
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.WriteAsync("Tenant is suspended.");
            return;
        }

        // Populate the scoped context. Everything downstream — EF Core's
        // global query filter, authorization handlers, background job
        // enqueueing — reads from this instead of re-deriving tenant identity.
        tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.ConnectionStringKey);

        await _next(context);
    }
}

Making tenant isolation impossible to forget

The shared-database model’s weakness is that isolation is only as good as the least careful query in the codebase. The fix isn’t code review discipline — that doesn’t scale past a handful of developers — it’s making the unsafe path harder to write than the safe one. EF Core’s global query filters are the standard tool for this:

// AppDbContext.cs
public class AppDbContext : DbContext
{
    private readonly ITenantContextAccessor _tenantContextAccessor;

    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantContextAccessor tenantContextAccessor) : base(options)
    {
        _tenantContextAccessor = tenantContextAccessor;
    }

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Applied to every entity implementing ITenantOwned — a developer
        // adding a new table gets tenant filtering for free by implementing
        // the interface, rather than having to remember to write a WHERE clause.
        var tenantId = _tenantContextAccessor.Current?.TenantId;

        modelBuilder.Entity<Order>().HasQueryFilter(o => o.TenantId == tenantId);
        modelBuilder.Entity<Invoice>().HasQueryFilter(i => i.TenantId == tenantId);
    }

    public override int SaveChanges()
    {
        // Belt-and-braces: stamp TenantId on insert so a developer can't
        // accidentally create a row for the wrong tenant, or forget to set it.
        foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.TenantId = _tenantContextAccessor.Current!.TenantId;
            }
        }
        return base.SaveChanges();
    }
}

Two caveats worth stating plainly. First, global query filters are bypassed by IgnoreQueryFilters() — that escape hatch should be reserved for a small, reviewed set of admin/support tooling, not something an application developer reaches for casually. Second, filters don’t help with raw SQL, stored procedures, or FromSqlRaw — if the platform uses any of those, tenant filtering has to be added explicitly at each call site, and that’s exactly where audits should focus. A repository layer that only exposes tenant-scoped methods (no GetByIdAsync(int id) without a tenant parameter, ever) closes this gap more reliably than relying on EF Core alone.

Authentication and authorization: where the real leaks happen

Isolation bugs in the data layer get caught by testing eventually, because they usually show up as visibly wrong data. Authorization bugs are more dangerous because they can be silent: an endpoint that checks “is this user an admin” without also checking “is this user an admin of this tenant” will happily let a legitimate admin of Tenant A modify a resource in Tenant B, if they can guess or enumerate its ID.

The reliable pattern is to make every authorization check tenant-scoped by construction, not by convention. Concretely:

  • Roles and permissions are tenant-scoped rows (TenantId, UserId, Role), not global — a user’s “admin” role means nothing without a tenant qualifier.
  • Every resource-level authorization handler checks both permission and that the resource’s TenantId matches the caller’s resolved tenant from ITenantContext — never trust a tenant ID that arrives in the request body or route.
  • Use ASP.NET Core’s resource-based authorization (IAuthorizationHandler against the actual entity, not just the claims) for anything that takes an ID in the URL, so GET /api/orders/{id} can’t return another tenant’s order just because the ID is guessable.
  • Treat “tenant ID mismatch” as a 404, not a 403 — a 403 confirms the resource exists in another tenant, which is itself a (minor) information leak.

This is also where the JWT-claim approach to tenant resolution pays for itself: because the tenant ID is baked into the signed token at issuance, an authorization handler comparing resource.TenantId == user.TenantIdClaim is checking against something the caller cannot forge, rather than something they supplied in the request.

Configuration, feature flags, and plan limits per tenant

Enterprise SaaS pricing tiers are rarely just “more of the same” — different tenants need different feature sets, different rate limits, and sometimes genuinely different behavior (custom fields, different SSO providers, different retention windows). Hard-coding these as if (tenant.Plan == "Enterprise") scattered through the codebase ages badly. A cleaner approach is a tenant configuration document (JSON in the tenant row, or a dedicated TenantSettings table) resolved once per request alongside ITenantContext, combined with a feature-flag service (Azure App Configuration’s feature management, or a simpler in-house table) keyed by tenant ID. The goal is that “does tenant X have feature Y” is always one lookup, never a chain of conditionals — that’s what keeps adding the fortieth tenant-specific toggle from becoming an archaeology exercise for whoever touches that code next.

The hooks billing actually needs

Full billing systems (Stripe integration, invoice generation, dunning) are their own project, but the architecture needs a few things in place from the start or retrofitting them later means touching every feature:

  • Usage events, not just current state. Emit an event ({TenantId, EventType, Quantity, Timestamp}) whenever something billable happens — an API call, a document processed, a seat added — even before a metered pricing plan exists. Reconstructing historical usage from application logs after the fact is painful; capturing it as a first-class event stream from day one is not.
  • Plan limits enforced at the boundary, not scattered in business logic. A single middleware or filter that checks “has this tenant exceeded its plan’s request quota” belongs in the same place tenant resolution happens, so enforcement doesn’t drift out of sync with pricing changes.
  • Idempotency on usage recording. Retried requests must not double-count usage; usage events should carry an idempotency key.

Background jobs need their own tenant context

A background job runs outside an HTTP request, so there’s no request pipeline to resolve a tenant from a subdomain or JWT — this is the most common place teams accidentally build a cross-tenant bug, because it’s easy to assume ITenantContext will just be populated the way it is in a controller.

Two safe patterns: either a job is scoped to exactly one tenant, and the tenant ID is passed explicitly as a job parameter (never inferred from ambient state), or a job legitimately needs to process all tenants, and it does so by iterating a known tenant list and creating a fresh scoped ITenantContext per tenant — never a job that queries “across tenants” against a shared context.

// TenantAwareReportJob.cs
// Runs nightly across all active tenants. Each tenant is processed in its
// own DI scope with its own ITenantContext, so a filter mistake in report
// generation can't leak into the next tenant's report.
public class TenantAwareReportJob
{
    private readonly ITenantStore _tenantStore;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TenantAwareReportJob> _logger;

    public TenantAwareReportJob(
        ITenantStore tenantStore,
        IServiceScopeFactory scopeFactory,
        ILogger<TenantAwareReportJob> logger)
    {
        _tenantStore = tenantStore;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var activeTenants = await _tenantStore.GetAllActiveAsync();

        foreach (var tenant in activeTenants)
        {
            // A fresh DI scope per tenant means a fresh DbContext, a fresh
            // ITenantContext, and no possibility of state leaking from the
            // previous tenant's iteration via a cached scoped service.
            using var scope = _scopeFactory.CreateScope();
            var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
            tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.ConnectionStringKey);

            try
            {
                var reportService = scope.ServiceProvider.GetRequiredService<IReportService>();
                await reportService.GenerateNightlyReportAsync(cancellationToken);
            }
            catch (Exception ex)
            {
                // One tenant's failure must not abort the batch for everyone else.
                _logger.LogError(ex, "Report generation failed for tenant {TenantId}", tenant.Id);
            }
        }
    }
}

Logging and observability without cross-tenant exposure

The operational question that comes up in every incident review is “what happened to tenant X’s requests in the last hour” — and the platform needs to answer it without giving the engineer investigating tenant X’s issue a way to also see tenant Y’s data. The practical setup: tag every log entry and trace span with TenantId as a structured field (not string-interpolated into the message — it needs to be a queryable dimension in whatever sink is used, Application Insights or otherwise), and enforce access to logs the same way access to data is enforced — support engineers query by tenant ID and their access to that query should itself be scoped and audited, the same principle discussed in why enterprise APIs get slow at scale and how to diagnose it, where per-tenant tracing is also what makes it possible to tell whether a slowdown is systemic or one noisy tenant. Correlation IDs should carry the tenant ID through to any downstream service calls (including calls into a document pipeline like the one described in building a production-ready RAG system for enterprise documents, if the platform includes one) so an incident can be traced end to end without re-deriving tenant context at each hop.

Scaling and the noisy-neighbor problem

In a shared-database model, one tenant running an unusually heavy reporting query, importing a large dataset, or simply growing much faster than the others can degrade response times for everyone sharing that database. This is the noisy-neighbor problem, and it’s the main argument for keeping the isolation model flexible rather than committing to pure shared-database forever.

Signs it’s time to move a tenant to isolated infrastructure: a single tenant consistently accounts for a disproportionate share of database CPU or IO; a tenant’s data volume is an order of magnitude larger than the median tenant, such that its indexes and query plans behave differently from everyone else’s; or a customer’s contract requires physical data isolation regardless of technical necessity. When that happens, the practical move is a live migration of that tenant’s rows into its own database, with the tenant-resolution layer redirecting its ConnectionStringKey afterward — which is precisely why resolving the connection string per tenant at the middleware layer, rather than hard-coding a single connection string application-wide, matters from the first line of code, not just at the point where the first large tenant shows up. Teams considering this kind of hybrid isolation strategy from the outset, rather than as an emergency migration, are a good fit for the kind of architecture review work covered under our services.

When not to build it this way

Multi-tenancy adds real complexity — it’s worth being honest about when a simpler model is the better call. If the product will realistically only ever serve a handful of large enterprise customers (single digits to low tens), database-per-tenant with minimal shared infrastructure is simpler to reason about than building generalized tenant-resolution middleware and global query filters for a case that doesn’t need them. And if the product is internal tooling or single-tenant by nature, none of this applies — resist the urge to build multi-tenant abstractions speculatively; they carry ongoing cost (every feature now has to consider tenant scoping) that isn’t worth paying until there’s a second tenant to justify it.

Key takeaways

  • Pick the isolation model based on tenant count, size variance, and compliance needs — shared-database-with-TenantId is cheapest but pushes correctness entirely onto application code; database-per-tenant is safest but doesn’t scale operationally past a moderate tenant count; a hybrid (shared by default, isolated for large/regulated tenants) is often the pragmatic answer.
  • Resolve the tenant as early as possible in the request pipeline, prefer a signed JWT claim over a client-supplied header or subdomain when both are available, and never let downstream code re-derive tenant identity independently.
  • Make isolation structural, not procedural: EF Core global query filters plus a repository layer that can’t expose an un-scoped query close off the most common way shared-database isolation breaks.
  • Authorization bugs, not data-layer bugs, are the more common source of real cross-tenant leaks — every resource-level check needs both a permission check and a tenant-match check, and a tenant mismatch should return 404, not 403.
  • Background jobs need their own explicit tenant context per unit of work; there is no ambient request pipeline to resolve it from, which is exactly why it’s easy to get wrong.
  • Design logging, usage metering, and connection routing to be tenant-aware from day one — retrofitting any of the three after the fact touches nearly every part of the system.

Was this article helpful?

Working through a similar engineering problem? Talk to the HzMinds engineering team.

Talk to us