Skip to main content
Version: Current

Multi-Tenancy Integration Guide (.NET)

If you want the shortest working path first, start with Verified .NET Quickstart. This guide is the fuller package-by-package setup.

Follow the steps below. All packages are independent, so install only what you need.

Step 1: Install packages

Core package (always required):

dotnet add package PrimusSaaS.MultiTenancy

ASP.NET Core middleware (recommended for web APIs):

dotnet add package PrimusSaaS.MultiTenancy.AspNetCore

EF Core write enforcement (production databases):

dotnet add package PrimusSaaS.MultiTenancy.EFCore

In-Memory membership store (development and testing only):

dotnet add package PrimusSaaS.MultiTenancy.InMemory

Step 2: Configure Program.cs

Register the core service and ASP.NET Core accessor, then add the middleware in order:

using PrimusSaaS.MultiTenancy.DependencyInjection;
using PrimusSaaS.MultiTenancy.AspNetCore.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Core: registers resolver, resolution strategies, and options
builder.Services.AddPrimusMultiTenancy(options =>
{
builder.Configuration.GetSection("MultiTenancy").Bind(options);
});

// ASP.NET Core: registers HttpContext-backed accessor
builder.Services.AddPrimusMultiTenancyAspNetCore();

builder.Services.AddAuthentication(/* your scheme */);
builder.Services.AddAuthorization();
builder.Services.AddControllers();

var app = builder.Build();

app.UseAuthentication();

// Middleware order matters:
app.UsePrimusTenantResolution(); // resolves TenantContext from the request
app.UsePrimusTenantIsolation(); // blocks authenticated requests without a tenant (401)
app.UsePrimusTenantRateLimiting(); // optional: per-tenant rate limiting
app.UsePrimusTenantEntitlements(); // optional: enforces endpoint feature requirements by tier

app.UseAuthorization();
app.MapControllers();
app.Run();

Step 3: Configure appsettings.json

{
"MultiTenancy": {
"TenantClaimType": "tid",
"TenantHeaderName": "X-Tenant-Id",
"TenantRouteKey": "tenantId",
"EnableSubdomainResolution": false,
"RequireTenantOnAuthenticatedRequests": true,
"EnableTenantRateLimiting": true,
"RateLimitMaxRequests": 100,
"RateLimitWindow": "00:01:00"
}
}

Options reference

OptionTypeRequiredDefaultPossible ValuesDescriptionEdge Cases / Error Handling
TenantClaimTypestringNo"tid"Any non-empty stringJWT claim name used by the claims strategyAn empty string causes the claims strategy to skip silently — no tenant is resolved and no exception is thrown.
TenantHeaderNamestringNo"X-Tenant-Id"Any non-empty stringHTTP header name used by the header strategyAn empty string causes the header strategy to skip silently.
TenantRouteKeystringNo"tenantId"Any identifier matching a route parameterRoute parameter name used by the route strategyIf the key is absent from the route template the strategy always skips.
EnableSubdomainResolutionboolNofalsetrue, falseEnables the subdomain strategy (read from Host header)With no subdomain present (e.g., localhost) the strategy returns no result. Do not enable this in environments that do not use subdomain-per-tenant routing.
SubdomainDevelopmentHostsHashSet<string>Nolocalhost, localtest.meRoot hostsAllows two-part hosts like acme.localhost in development while preserving strict behavior for non-listed roots.
RequireTenantOnAuthenticatedRequestsboolNotruetrue, falseWhen true, isolation middleware returns 401 Unauthorized for authenticated requests with no resolved tenantSet to false for public or single-tenant APIs. When false, UsePrimusTenantIsolation() is a no-op per request.
EnableTenantRateLimitingboolNotruetrue, falseEnables per-tenant rate limiting in TenantRateLimitMiddlewareWhen false, UsePrimusTenantRateLimiting() passes all requests through. See the warning below about in-process scope.
RateLimitMaxRequestsintNo100Any integer ≥ 1Maximum requests per tenant per windowValues below 1 rate-limit every request immediately. Very large values (> 10,000) are accepted but reduce protective value.
RateLimitWindowTimeSpanNo00:01:00Any valid TimeSpan string (hh:mm:ss)Sliding window durationAn invalid string in appsettings.json throws FormatException at startup. Very short windows (< 1 s) may behave unexpectedly under high concurrency.
PerTierRateLimitsDictionary<string, TenantRateLimitPolicy>NoEmptyTier names (free, pro, enterprise)Tier-level fallback rate-limits when no per-tenant override exists.
TenantTierMetadataKeystringNo"tier"Any non-empty stringMetadata key read from tenant record to resolve tier policies.
DefaultTenantTierstring?NonullTier name or nullFallback tier when tenant metadata does not include TenantTierMetadataKey.
PerTierEntitlementsDictionary<string, HashSet<string>>NoEmptyTier names -> feature keysTier-based feature allow-list used by UsePrimusTenantEntitlements() and RequirePrimusFeature(...).
Rate limiting defaults to in-memory only when no distributed cache is registered

AddPrimusMultiTenancyAspNetCore() now auto-selects DistributedTenantRateLimiter when an IDistributedCache is available. If no distributed cache is registered, it falls back to InMemoryTenantRateLimiter (process-local counters).

For horizontally scaled deployments, register a distributed cache:

// Register distributed cache before or after AddPrimusMultiTenancyAspNetCore()
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

You can still force explicit behavior with AddPrimusMultiTenancyDistributedRateLimiting().

Disabling isolation or rate limiting

To run without tenant enforcement (for internal or single-tenant APIs), set:

{
"MultiTenancy": {
"RequireTenantOnAuthenticatedRequests": false,
"EnableTenantRateLimiting": false
}
}

Or omit UsePrimusTenantIsolation() and UsePrimusTenantRateLimiting() from the pipeline.

Step 4: EF Core write enforcement

The EFCore package provides a SaveChanges interceptor that:

  • Automatically sets TenantId on any ITenantOwnedEntity added without one.
  • Throws InvalidOperationException on cross-tenant Modified or Deleted entities.

Register the interceptor:

using PrimusSaaS.MultiTenancy.EFCore.DependencyInjection;

builder.Services.AddPrimusMultiTenancyEfCore<AppDbContext>();

builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
options.UsePrimusMultiTenancy(serviceProvider); // attaches the interceptor
});

Configure your DbContext model so owned entities get the TenantId column:

using PrimusSaaS.MultiTenancy.EFCore.Extensions;

public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddPrimusMultiTenancy(); // marks TenantId as required on all ITenantOwnedEntity types
}
}

Mark your entities:

using PrimusSaaS.MultiTenancy.Abstractions;

public class Invoice : ITenantOwnedEntity
{
public Guid Id { get; set; }
public string TenantId { get; set; } = string.Empty;
public decimal Amount { get; set; }
}

Step 5: Development and testing with In-Memory store

For local development and tests, register the In-Memory membership store and seed tenant memberships after the app is built:

using PrimusSaaS.MultiTenancy.InMemory.DependencyInjection;
using PrimusSaaS.MultiTenancy.InMemory.Stores;
using PrimusSaaS.MultiTenancy.Models;

// Register the In-Memory store (no configuration parameters)
builder.Services.AddPrimusMultiTenancyInMemory();

var app = builder.Build();

// Seed test memberships after building the service provider
var store = app.Services.GetRequiredService<InMemoryTenantMembershipStore>();
store.Seed("user-123", new[]
{
new TenantMembership("user-123", "tenant-001"),
new TenantMembership("user-123", "tenant-002"),
});

The In-Memory store satisfies ITenantMembershipStore so the DefaultMembershipTenantResolutionStrategy can resolve a tenant without a JWT claim or header.

caution

AddPrimusMultiTenancyInMemory() takes no parameters. Seeding must be done after builder.Build() by resolving InMemoryTenantMembershipStore from the service provider and calling Seed() directly.

Step 6: Tenant management endpoints (optional)

The ASP.NET Core package ships opt-in endpoints for tenant CRUD/lifecycle operations, list/search, and summary metrics backed by ITenantStore. Add them after your route mapping:

using PrimusSaaS.MultiTenancy.AspNetCore.DependencyInjection;

var app = builder.Build();
// ...middleware...
app.MapControllers();

// Mount management endpoints (optional — secure before exposing)
app.MapPrimusTenantManagementEndpoints()
.RequireAuthorization("PlatformAdmin"); // recommended: lock down this group

Available routes:

MethodRouteDescription
GET/primus/tenantsList tenants (search, status, skip, take)
GET/primus/tenants/summaryAggregate counts by status and tier
GET/primus/tenants/{tenantId}Retrieve a tenant record
PUT/primus/tenants/{tenantId}Create or update a tenant record
POST/primus/tenants/{tenantId}/activateSet status to Active
POST/primus/tenants/{tenantId}/suspendSet status to Suspended
POST/primus/tenants/{tenantId}/soft-deleteSet status to Deleted
DELETE/primus/tenants/{tenantId}Permanently delete a tenant record
POST/primus/tenants/{tenantId}/offboardOptional retention purge + hard-delete orchestration

Customize the route prefix by passing a pattern argument:

app.MapPrimusTenantManagementEndpoints("/admin/tenants");

Step 7: Tier feature gating (optional)

Use tier metadata for endpoint-level feature entitlements:

builder.Services.Configure<MultiTenancyOptions>(o =>
{
o.DefaultTenantTier = "free";
o.PerTierEntitlements["free"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"dashboard.view"
};
o.PerTierEntitlements["pro"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"dashboard.view",
"reports.export"
};
});

app.MapGet("/api/reports/export", ExportHandler)
.RequirePrimusFeature("reports.export");

When a tenant tier is not entitled, middleware returns 403 and emits TenantEntitlementDenied to ITenantAuditSink.

Reading tenant context in controllers

using PrimusSaaS.MultiTenancy.Abstractions;

[ApiController]
[Route("api/invoices")]
public class InvoicesController(ITenantContextAccessor tenantContext) : ControllerBase
{
[HttpGet]
[Authorize]
public IActionResult List()
{
var tenant = tenantContext.Current;
// tenant.TenantId — always populated
// tenant.DisplayName — optional, may be null
return Ok(new { tenantId = tenant?.TenantId, displayName = tenant?.DisplayName });
}
}

Composing with RBAC

Forward the resolved tenant identity to the RBAC module using the built-in composition helper. No RBAC package reference is required in the multi-tenancy project:

using PrimusSaaS.MultiTenancy.Abstractions;
using PrimusSaaS.MultiTenancy.Composition;

[ApiController]
[Route("api/documents")]
public class DocumentsController(
ITenantContextAccessor tenantContext,
IRbacService rbac) : ControllerBase
{
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(string id)
{
// ResolveTenantIdForRbac() returns null if no tenant is active
var tenantId = tenantContext.ResolveTenantIdForRbac();

var decision = await rbac.EvaluateAsync(new RbacAccessRequest(
PrincipalId: User.FindFirstValue(ClaimTypes.NameIdentifier)!,
Resource: "documents",
Action: "delete",
TenantId: tenantId));

if (!decision.IsAllowed)
return Forbid();

// ... delete logic
return NoContent();
}
}

Alternatively, resolve directly from a TenantContext? value:

var tenantId = tenantContext.Current.ResolveTenantIdForRbac();

Golden path — minimum working setup

Install both packages and add three lines to Program.cs:

builder.Services.AddPrimusMultiTenancy();
builder.Services.AddPrimusMultiTenancyAspNetCore();

// after UseAuthentication():
app.UsePrimusTenantResolution();
app.UsePrimusTenantIsolation();

Security: header strategy trust boundary

Trust boundary — never sole strategy for public APIs

HeaderTenantResolutionStrategy (strategy 2 in the default pipeline) accepts X-Tenant-Id at face value without verifying that the caller is an active member of the specified tenant. A malicious or misconfigured client can send any tenant ID and the strategy will accept it.

Use HeaderTenantResolutionStrategy only when:

  • The API is called exclusively by trusted internal services that set the header from their own verified context.
  • A JWT claim strategy (strategy 1) runs first and will resolve the tenant before the header strategy is evaluated on the same request.
  • You have replaced the default header strategy with MembershipValidatedTenantResolutionStrategy (see below).

Never configure HeaderTenantResolutionStrategy as the sole resolution strategy for a public API that allows untrusted clients. Doing so enables tenant enumeration and impersonation attacks.

Membership-validated tenant selection

For APIs where users may belong to multiple tenants and are allowed to select their active workspace, use MembershipValidatedTenantResolutionStrategy. It reads a tenant selection from a claim or the configured header and validates it against the caller's active memberships before accepting it:

builder.Services.AddPrimusMultiTenancy();
builder.Services.AddPrimusMultiTenancyAspNetCore();

// Registers MembershipValidatedTenantResolutionStrategy as an additional named strategy.
// Your ITenantMembershipStore must be registered first.
builder.Services.AddPrimusMultiTenancyMembershipValidatedSelection();

With membership validation active, a request sending X-Tenant-Id: acme-corp succeeds only if the authenticated principal is an active, non-expired member of acme-corp. Requests that do not pass this check receive tenant_membership_denied in the resolution failure details.


Tenant audit events

The module emits structured TenantAuditEvent records through ITenantAuditSink for key isolation and lifecycle events. The default sink writes to ILogger<LoggerTenantAuditSink> at Information level.

Events emitted

EventSource
TenantResolvedResolution middleware
TenantResolutionFailedResolution middleware
TenantIsolationBlockedTenantIsolationMiddleware
TenantSuspendedAccessTenantIsolationMiddleware
TenantDeletedAccessTenantIsolationMiddleware
SuperAdminBypassActivatedTenantIsolationMiddleware
RateLimitExceededTenantRateLimitMiddleware
CrossTenantWriteBlockedTenantSaveChangesInterceptor
TenantCreated / TenantUpdatedManagement endpoints
TenantProvisionedManagement endpoints (after provisioning handler completes)
TenantActivated / TenantSuspended / TenantSoftDeletedManagement endpoints

Custom sink

Implement ITenantAuditSink and register it before calling AddPrimusMultiTenancy():

// Replace the default logger sink with your own (e.g., writes to Azure Monitor)
builder.Services.AddSingleton<ITenantAuditSink, MyAzureMonitorAuditSink>();
builder.Services.AddPrimusMultiTenancy();

Your implementation must not throw. If the sink can fail (e.g., network writes), wrap it in a try/catch internally.


Tenant provisioning hook

Implement ITenantProvisioningHandler to run ordered initialization steps when a tenant is created for the first time (via PUT /primus/tenants/{id}). The hook is not called on updates to existing tenants.

public sealed class MyProvisioningHandler(IStorageService storage) : ITenantProvisioningHandler
{
public async Task OnTenantCreatedAsync(TenantRecord tenant, CancellationToken ct = default)
{
await storage.CreateTenantBucketAsync(tenant.TenantId, ct);
// Seed default data, send welcome notification, etc.
}
}

// Register before AddPrimusMultiTenancy():
builder.Services.AddSingleton<ITenantProvisioningHandler, MyProvisioningHandler>();
builder.Services.AddPrimusMultiTenancy();

Implementations should be idempotent — they may be called more than once if retried after a transient failure. The default registration is a no-op (NullTenantProvisioningHandler).


Background context propagation

ITenantContextAccessor backed by HttpContextAccessor works only inside an ASP.NET Core request. For background jobs, hosted services, and message consumers, capture the context before leaving the request and restore it when the background work starts.

Step 1 — capture in the request

using PrimusSaaS.MultiTenancy.Resolution;

// Inside a request handler or request-scoped service:
var executionCtx = TenantExecutionContext.Capture(tenantContextAccessor);
backgroundQueue.Enqueue(() => ProcessAsync(executionCtx, serviceProvider));

Or when the tenant ID is known from a message header:

var executionCtx = TenantExecutionContext.ForTenant(message.TenantId);

Step 2 — restore in the background worker

using PrimusSaaS.MultiTenancy.Infrastructure;

// Inside the background job / hosted service:
using var scope = serviceProvider.CreateScope();

var filterProvider = scope.ServiceProvider.GetRequiredService<TenantQueryFilterProvider>();

using (executionCtx.Apply(filterProvider))
{
// All EF Core and ITenantContextAccessor calls within this using block
// operate with the restored tenant scope.
await DoTenantSafeWorkAsync(scope.ServiceProvider);
}

Use AmbientTenantContextAccessor (not HttpContextTenantContextAccessor) in the background scope so ITenantContextAccessor.Current reads the restored context:

// Register in the background worker's DI scope or at composition root:
services.AddSingleton<ITenantContextAccessor, AmbientTenantContextAccessor>();

EF Core: allowUnscopedReads caution

ModelBuilderExtensions.AddPrimusMultiTenancy() accepts an optional allowUnscopedReads: true parameter. Setting this to true disables the guard that throws when tenant-owned entities are queried without an active tenant context.

Production anti-pattern

Setting allowUnscopedReads: true masks missing tenant filter provider initialization and can silently return rows from all tenants. This is a cross-tenant data leak risk.

Only use allowUnscopedReads: true in controlled environments (e.g., background migration scripts that must scan all rows) and never in code paths that return data to end users.


Pre-go-live checklist

Before deploying a multi-tenant API to production, verify each of the following:

CheckWhy
AddPrimusMultiTenancyDistributedRateLimiting() is configuredDefault in-memory limiter resets on restart and is not shared across replicas
HeaderTenantResolutionStrategy is not the sole resolution strategy on public APIsHeader strategy accepts untrusted values without membership check
ITenantMembershipStore is backed by a real database (not InMemory)In-Memory store does not persist across restarts
MapPrimusTenantManagementEndpoints() is protected by authorizationManagement endpoints mutate tenant lifecycle; should require elevated claims
ITenantAuditSink routes to a durable store (not just console logs)Audit events are required for SOC 2 / ISO 27001 review
ITenantProvisioningHandler is idempotentThe handler may be retried on transient failures
allowUnscopedReads: false in AddPrimusMultiTenancy() (or omitted)Setting true disables the cross-tenant read guard
AddPrimusMultiTenancyDistributedMembershipCaching() is configured for scaled deploymentsIn-process membership cache is not shared across instances
Rate-limit overrides are set for high-volume tenants in PerTenantRateLimitsGlobal defaults may reject legitimate high-throughput tenants
RejectConflictingTenantSignals: true (default)Fail-closed on claim/header mismatch; set false only with explicit justification

Your JWT's tid claim becomes the TenantContext.TenantId for every request.