Reviews multi-tenancy at the ASP.NET Core API/application layer — tenant resolution, DI lifetime correctness, and connection-routing for both shared-database and database-per-tenant models. Flags ad-hoc per-endpoint tenant resolution instead of centralized middleware, Singleton-scoped tenant context leaking across requests, stale per-request connection strings, missing tenant catalog caching, and silent fallback on unresolved tenants. Cross-references pilot-sql's sql-multitenancy skill for EF...
Scanned 9/6/2026
Install to Claude Code
npx -y skills add AgenticPawan/FullStack-Pilot --skill dotnet-multitenancy --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Dotnet Multitenancy?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/agenticpawan-dotnet-multitenancy)More formats (shields.io, HTML) on the badges page.
---
name: dotnet-multitenancy
description: Reviews multi-tenancy at the ASP.NET Core API/application layer — tenant resolution, DI lifetime correctness, and connection-routing for both shared-database and database-per-tenant models. Flags ad-hoc per-endpoint tenant resolution instead of centralized middleware, Singleton-scoped tenant context leaking across requests, stale per-request connection strings, missing tenant catalog caching, and silent fallback on unresolved tenants. Cross-references pilot-sql's sql-multitenancy skill for EF Core query-filter enforcement.
when_to_use: multi-tenancy, tenant resolution, ITenantContext, tenant middleware, database per tenant, shared database discriminator, tenant connection string, tenant catalog, DI lifetime tenant, subdomain tenant, tenant header, unresolved tenant, tenant onboarding
---
## Tenancy models
| Model | Isolation | Cost | When to pick |
|-------|-----------|------|---------------|
| Shared DB + discriminator column (`TenantId`) | Row-level, app-enforced | Lowest | Many small tenants, low compliance bar; enforce with EF Core `HasQueryFilter` — see `pilot-sql`'s `sql-multitenancy` skill for that layer |
| Shared DB + schema-per-tenant | Schema-level | Medium | Moderate isolation need without per-tenant infra cost |
| Separate database per tenant | Full physical isolation | Highest | Strict compliance/data-residency requirements, large or enterprise tenants, per-tenant backup/restore/scale needs |
This skill covers tenant resolution and DI/connection-routing concerns that sit above the
data layer and apply to any of the three models. Query-filter enforcement for the shared-DB
model is covered by `pilot-sql`'s `sql-multitenancy` skill (`HasQueryFilter`,
`IgnoreQueryFilters` policy, cross-tenant test scaffolding) — do not duplicate that content
here; reference it when a finding is actually a missing query filter.
---
## Standard IDs
| ID | Severity | What it checks |
|----|----------|----------------|
| TN-001 | P0 | Tenant resolved ad-hoc per-endpoint instead of centralized middleware |
| TN-002 | P0 | Tenant context/resolver registered with Singleton lifetime (cross-request leak) |
| TN-003 | P1 | Shared-DB entity query not scoped to tenant (defer detail to `pilot-sql`'s `sql-multitenancy`) |
| TN-004 | P0 | Database-per-tenant: connection string resolved once at startup instead of per-request |
| TN-005 | P1 | Database-per-tenant: no tenant-to-connection-string catalog/registry, or catalog lookup not cached |
| TN-006 | P0 | Unresolved tenant identifier silently falls back to a default tenant instead of 400/404 |
---
## Check A — Centralized tenant resolution middleware
### Detection
1. Grep for tenant-lookup logic (`Request.Headers["X-Tenant-Id"]`, `HttpContext.Request.Host`,
`User.FindFirst("tenant_id")`) appearing inside individual controllers/endpoints rather
than in a single middleware component.
2. If more than one endpoint independently parses the tenant identifier, flag TN-001 —
resolution logic and its edge cases (missing header, unknown subdomain) diverge over time.
### BAD — resolved inline, per controller
```csharp
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var tenantId = Request.Headers["X-Tenant-Id"].ToString(); // duplicated everywhere
var orders = await _orderService.GetForTenantAsync(tenantId);
return Ok(orders);
}
}
```
### GOOD — centralized middleware populating a scoped `ITenantContext`
```csharp
public interface ITenantContext
{
string TenantId { get; }
void SetTenant(string tenantId);
}
public class TenantContext : ITenantContext
{
public string TenantId { get; private set; } = string.Empty;
public void SetTenant(string tenantId) => TenantId = tenantId;
}
public class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
public TenantResolutionMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
if (string.IsNullOrEmpty(tenantId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Missing tenant identifier.");
return;
}
tenantContext.SetTenant(tenantId);
await _next(context);
}
}
// Program.cs
builder.Services.AddScoped<ITenantContext, TenantContext>();
app.UseMiddleware<TenantResolutionMiddleware>();
```
---
## Check B — DI lifetime correctness for tenant context
### Detection
1. Grep `AddSingleton<ITenantContext` / `AddSingleton<TenantContext`.
2. A Singleton tenant context is populated once and then shared by every concurrent
request thereafter — this is a cross-tenant data leak, not just a bug. It must be
`Scoped` (one instance per request).
### BAD — Singleton tenant context
```csharp
// First request sets TenantId="acme"; every later request on this instance,
// regardless of which tenant it belongs to, now reads "acme".
builder.Services.AddSingleton<ITenantContext, TenantContext>();
```
### GOOD — Scoped tenant context
```csharp
builder.Services.AddScoped<ITenantContext, TenantContext>();
```
**Detection rule:** any `AddSingleton` registration of a type whose name matches
`*TenantContext`, `*TenantResolver`, or `*TenantAccessor` is an automatic TN-002 finding
unless the type is provably immutable/stateless (holds no per-request mutable field).
---
## Check C — Shared-DB tenant scoping (cross-reference)
### Detection
1. For the shared-DB + discriminator-column model, check that every `DbSet<T>` for a
tenant-owned entity is covered by a global query filter.
2. This is deliberately shallow here — full detection steps (`HasQueryFilter` coverage,
`IgnoreQueryFilters` justification policy, cross-tenant test scaffold, SQL Server RLS)
live in `pilot-sql`'s `sql-multitenancy` skill. Emit TN-003 as a pointer finding and
direct the reviewer there rather than re-running that logic.
```csharp
// If this pattern is found without a corresponding HasQueryFilter in OnModelCreating,
// raise TN-003 and reference pilot-sql's sql-multitenancy skill (see MT-001) for the fix.
var orders = await _db.Orders.Where(o => o.Status == OrderStatus.Open).ToListAsync();
```
---
## Check D — Database-per-tenant: connection string resolved per request, not at startup
### Detection
1. Grep `AddDbContext<AppDbContext>(options => options.UseSqlServer(...))` in `Program.cs`
for a connection string read from `IConfiguration` at container-build time.
2. In the database-per-tenant model, this is wrong — the connection string must be
resolved from the current tenant on every request, not baked in once at startup.
### BAD — connection string fixed at startup
```csharp
// Program.cs — resolved once, at DI container build time
var connectionString = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// Every tenant, on every request, gets this same connection for the lifetime of the app.
```
### GOOD — connection string resolved per request from tenant context
```csharp
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var tenantContext = serviceProvider.GetRequiredService<ITenantContext>();
var catalog = serviceProvider.GetRequiredService<ITenantConnectionCatalog>();
var connectionString = catalog.GetConnectionString(tenantContext.TenantId);
options.UseSqlServer(connectionString);
});
```
Because `AddDbContext` resolves its options delegate per scope (per request, in ASP.NET
Core's default DI container), this pulls the current tenant's connection string fresh on
every request rather than reusing a stale one from startup.
---
## Check E — Missing or uncached tenant-to-connection-string catalog
### Detection
1. Look for the connection-string lookup itself: does a `ITenantConnectionCatalog` (or
equivalent) exist, and does it cache results, or does it hit the catalog database on
every single request?
2. No catalog abstraction at all → TN-005 (P1). Catalog exists but re-queries the catalog
DB on every call → also TN-005, lower-severity performance sub-finding.
### BAD — catalog DB hit on every request, no caching
```csharp
public class TenantConnectionCatalog : ITenantConnectionCatalog
{
private readonly CatalogDbContext _catalogDb;
public TenantConnectionCatalog(CatalogDbContext catalogDb) => _catalogDb = catalogDb;
public string GetConnectionString(string tenantId)
{
// Round-trips to the catalog database on every single request.
var tenant = _catalogDb.Tenants.Single(t => t.TenantId == tenantId);
return tenant.ConnectionString;
}
}
```
### GOOD — cached catalog lookup with expiry
```csharp
public class TenantConnectionCatalog : ITenantConnectionCatalog
{
private readonly CatalogDbContext _catalogDb;
private readonly IMemoryCache _cache;
public TenantConnectionCatalog(CatalogDbContext catalogDb, IMemoryCache cache)
{
_catalogDb = catalogDb;
_cache = cache;
}
public string GetConnectionString(string tenantId)
{
return _cache.GetOrCreate($"tenant-conn:{tenantId}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
var tenant = _catalogDb.Tenants.SingleOrDefault(t => t.TenantId == tenantId);
if (tenant is null)
{
throw new TenantNotFoundException(tenantId);
}
return tenant.ConnectionString;
});
}
}
```
---
## Check F — Unresolved tenant silently falls back to a default
### Detection
1. Trace what happens when tenant resolution fails to find a match (unknown subdomain,
unrecognized header value, missing claim).
2. If the code falls back to a "default" or first-configured tenant instead of returning
`400 Bad Request` / `404 Not Found`, flag TN-006 — this can silently route a user's
request into the wrong tenant's data store.
### BAD — silent fallback to a default tenant
```csharp
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
if (string.IsNullOrEmpty(tenantId) || !await _catalog.ExistsAsync(tenantId))
{
tenantId = "default"; // silently mis-routes the request
}
tenantContext.SetTenant(tenantId);
await _next(context);
}
```
### GOOD — explicit failure on unresolved tenant
```csharp
public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
if (string.IsNullOrEmpty(tenantId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Missing tenant identifier.");
return;
}
if (!await _catalog.ExistsAsync(tenantId))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("Unknown tenant.");
return;
}
tenantContext.SetTenant(tenantId);
await _next(context);
}
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!