skills/dotnet-multi-tenant/SKILL.md
Implements Multi-Tenant pattern in .NET APIs with per-tenant database isolation, per-tenant JWT secrets, ACL layer with automatic TenantId propagation via DelegatingHandler, and dynamic DbContext factory. Covers TenantMiddleware, TenantContext, TenantResolver, TenantHeaderHandler, TenantDbContextFactory, and dynamic JWT configuration.
npx skillsauth add landim32/awesome-ai-skills dotnet-multi-tenantInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are an expert assistant that helps developers implement the Multi-Tenant pattern in .NET APIs. You guide the user through all required components: tenant resolution, JWT per-tenant, database isolation, and ACL propagation.
The user will describe what to create or modify: $ARGUMENTS
Before generating code:
appsettings.json — Check for existing tenant configurationProgram.cs or Startup.cs — Understand current DI and middleware setup┌─────────────────────────────────────────────────────────┐
│ API Layer │
│ │
│ ┌─────────────────┐ ┌────────────────────────────┐ │
│ │ TenantMiddleware │───▶│ HttpContext.Items["TenantId"]│ │
│ │ (runs before │ └────────────────────────────┘ │
│ │ auth pipeline) │ │
│ └─────────────────┘ │
│ │
│ Unauthenticated endpoints → Header: X-Tenant-Id │
│ Authenticated endpoints → JWT claim: tenant_id │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ACL Layer │
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ TenantHeaderHandler │──▶│ Injects X-Tenant-Id │ │
│ │ (DelegatingHandler) │ │ into ALL HttpClient │ │
│ │ │ │ requests automatically │ │
│ └──────────────────────┘ └────────────────────────┘ │
│ │
│ TenantId source: appsettings.json → Tenant:DefaultId │
│ NEVER passed as method parameter │
└─────────────────────────────────────────────────────────┘
| Concern | Strategy |
|---|---|
| Database | Separate ConnectionString per tenant |
| Authentication | Separate JwtSecret per tenant |
| Tenant Resolution (API) | Header or JWT claim |
| Tenant Resolution (ACL) | appsettings.json config |
appsettings.jsonEach tenant has its own ConnectionString and JwtSecret:
{
"Tenant": {
"DefaultTenantId": "tenant-a"
},
"Tenants": {
"tenant-a": {
"ConnectionString": "Server=srv1;Database=TenantA_DB;User Id=sa;Password=***;",
"JwtSecret": "super-secret-key-tenant-a-256bits"
},
"tenant-b": {
"ConnectionString": "Server=srv2;Database=TenantB_DB;User Id=sa;Password=***;",
"JwtSecret": "super-secret-key-tenant-b-256bits"
}
}
}
Provides the current tenant ID within the API request scope.
File: Application/Interfaces/ITenantContext.cs
public interface ITenantContext
{
string TenantId { get; }
}
File: Application/Services/TenantContext.cs
public class TenantContext : ITenantContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantContext(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string TenantId
{
get
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
throw new InvalidOperationException("No active HTTP context.");
// Authenticated: resolve from JWT claim
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(claimTenant))
return claimTenant;
// Unauthenticated: resolve from middleware-set item
if (context.Items.TryGetValue("TenantId", out var headerTenant)
&& headerTenant is string tenantStr
&& !string.IsNullOrEmpty(tenantStr))
return tenantStr;
throw new InvalidOperationException(
"TenantId could not be resolved from JWT claim or X-Tenant-Id header.");
}
}
}
Registration: Scoped
Runs before the authentication middleware. Extracts X-Tenant-Id from the request header and stores it in HttpContext.Items.
File: Api/Middlewares/TenantMiddleware.cs
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantId)
&& !string.IsNullOrWhiteSpace(tenantId))
{
context.Items["TenantId"] = tenantId.ToString();
}
await _next(context);
}
}
Registration in Program.cs:
// MUST be registered BEFORE UseAuthentication / UseAuthorization
app.UseMiddleware<TenantMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
Resolves tenant configuration from appsettings.json. Used by the ACL layer only.
File: ACL/Interfaces/ITenantResolver.cs
public interface ITenantResolver
{
string TenantId { get; }
string ConnectionString { get; }
string JwtSecret { get; }
}
File: ACL/Services/TenantResolver.cs
public class TenantResolver : ITenantResolver
{
private readonly IConfiguration _configuration;
public TenantResolver(IConfiguration configuration)
{
_configuration = configuration;
}
public string TenantId
{
get
{
var tenantId = _configuration["Tenant:DefaultTenantId"];
if (string.IsNullOrEmpty(tenantId))
throw new InvalidOperationException(
"Tenant:DefaultTenantId is not configured in appsettings.json.");
return tenantId;
}
}
public string ConnectionString
{
get
{
var cs = _configuration[$"Tenants:{TenantId}:ConnectionString"];
if (string.IsNullOrEmpty(cs))
throw new InvalidOperationException(
$"ConnectionString not found for tenant '{TenantId}'. " +
$"Expected key: Tenants:{TenantId}:ConnectionString");
return cs;
}
}
public string JwtSecret
{
get
{
var secret = _configuration[$"Tenants:{TenantId}:JwtSecret"];
if (string.IsNullOrEmpty(secret))
throw new InvalidOperationException(
$"JwtSecret not found for tenant '{TenantId}'. " +
$"Expected key: Tenants:{TenantId}:JwtSecret");
return secret;
}
}
}
Registration: Scoped
A DelegatingHandler that automatically injects the X-Tenant-Id header into every HTTP request made by ACL HttpClient instances. The TenantId is never passed as a method parameter.
File: ACL/Handlers/TenantHeaderHandler.cs
public class TenantHeaderHandler : DelegatingHandler
{
private readonly IConfiguration _configuration;
public TenantHeaderHandler(IConfiguration configuration)
{
_configuration = configuration;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var tenantId = _configuration["Tenant:DefaultTenantId"];
if (!string.IsNullOrEmpty(tenantId))
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
return await base.SendAsync(request, cancellationToken);
}
}
Registration: Transient
services.AddTransient<TenantHeaderHandler>();
// Register for EVERY ACL HttpClient:
services.AddHttpClient<IMyAclService, MyAclService>()
.AddHttpMessageHandler<TenantHeaderHandler>();
Since each tenant has its own JwtSecret, JWT validation cannot use a static key. Use IssuerSigningKeyResolver to dynamically resolve the signing key based on the tenant_id claim in the token.
JWT Validation Setup (in Program.cs or auth config):
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
var tenantId = jwt.Claims
.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
var secret = configuration[$"Tenants:{tenantId}:JwtSecret"];
if (string.IsNullOrEmpty(secret))
throw new SecurityTokenException(
$"JwtSecret not found for tenant: {tenantId}");
return new[]
{
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
};
},
ValidateIssuer = false,
ValidateAudience = false
};
});
Token Generation (Login):
TenantId from X-Tenant-Id headerJwtSecret via IConfiguration["Tenants:{tenantId}:JwtSecret"]tenant_id claim in the payloadJwtSecretpublic interface ITokenService
{
string GenerateToken(string tenantId, string userId, string email);
}
public class TokenService : ITokenService
{
private readonly IConfiguration _configuration;
public TokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(string tenantId, string userId, string email)
{
var secret = _configuration[$"Tenants:{tenantId}:JwtSecret"]
?? throw new InvalidOperationException(
$"JwtSecret not found for tenant: {tenantId}");
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim("tenant_id", tenantId),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Email, email)
};
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(8),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Creates DbContext instances at runtime using the tenant-specific ConnectionString. Does NOT modify the existing AppDbContext.
File: ACL/Interfaces/ITenantDbContextFactory.cs
public interface ITenantDbContextFactory
{
AppDbContext CreateDbContext();
}
File: ACL/Services/TenantDbContextFactory.cs
public class TenantDbContextFactory : ITenantDbContextFactory
{
private readonly ITenantResolver _tenantResolver;
public TenantDbContextFactory(ITenantResolver tenantResolver)
{
_tenantResolver = tenantResolver;
}
public AppDbContext CreateDbContext()
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlServer(_tenantResolver.ConnectionString);
return new AppDbContext(optionsBuilder.Options);
}
}
Registration: Scoped
// --- HTTP Context ---
services.AddHttpContextAccessor();
// --- Tenant Services ---
services.AddScoped<ITenantContext, TenantContext>();
services.AddScoped<ITenantResolver, TenantResolver>();
services.AddScoped<ITenantDbContextFactory, TenantDbContextFactory>();
services.AddScoped<ITokenService, TokenService>();
// --- TenantHeaderHandler ---
services.AddTransient<TenantHeaderHandler>();
// --- DbContext via Factory ---
services.AddScoped(sp =>
sp.GetRequiredService<ITenantDbContextFactory>().CreateDbContext());
// --- ACL HttpClients (add handler to ALL) ---
services.AddHttpClient<IMyAclService, MyAclService>()
.AddHttpMessageHandler<TenantHeaderHandler>();
// Repeat for every ACL HttpClient:
// services.AddHttpClient<IOtherAclService, OtherAclService>()
// .AddHttpMessageHandler<TenantHeaderHandler>();
// --- JWT Authentication ---
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* IssuerSigningKeyResolver as shown above */ });
src/
├── Api/
│ ├── Middlewares/
│ │ └── TenantMiddleware.cs
│ └── Program.cs
├── ACL/
│ ├── Handlers/
│ │ └── TenantHeaderHandler.cs
│ ├── Interfaces/
│ │ ├── ITenantResolver.cs
│ │ └── ITenantDbContextFactory.cs
│ └── Services/
│ ├── TenantResolver.cs
│ └── TenantDbContextFactory.cs
└── Application/
├── Interfaces/
│ └── ITenantContext.cs
└── Services/
└── TenantContext.cs
DbSets, or OnModelCreating in the existing AppDbContextAddDbContext — use the scoped factory insteadJwtSecret — each tenant has its ownTenantId as a method parameter — propagate exclusively via X-Tenant-Id header through TenantHeaderHandlerTenantId from request body — only from header or JWTIssuerSigningKeyResolver, only read the token to extract tenant_id — signature validation occurs immediately after with the resolved keyScoped; TenantHeaderHandler is Transientpublic class ProductAclService : IProductAclService
{
private readonly HttpClient _httpClient;
public ProductAclService(HttpClient httpClient)
{
_httpClient = httpClient; // TenantHeaderHandler already adds X-Tenant-Id
}
public async Task<List<ProductDto>> GetAllAsync()
{
return await _httpClient.GetFromJsonAsync<List<ProductDto>>("api/products");
}
}
[AllowAnonymous]
[HttpGet("public/products")]
public async Task<IActionResult> GetPublicProducts()
{
// TenantId resolved from X-Tenant-Id header
var data = await _service.GetAllAsync();
return Ok(data);
}
[Authorize]
[HttpGet("private/products")]
public async Task<IActionResult> GetPrivateProducts()
{
// TenantId resolved from JWT claim
var data = await _service.GetAllAsync();
return Ok(data);
}
TenantMiddleware.csITenantContext.cs and TenantContext.csITenantResolver.cs and TenantResolver.cs with TenantId, ConnectionString, and JwtSecretTenantHeaderHandler.cs — DelegatingHandler injecting X-Tenant-Id into all ACL HttpClientsITenantDbContextFactory.cs and TenantDbContextFactory.csAddJwtBearer configuration with dynamic IssuerSigningKeyResolverITokenService / TokenService) signing with the tenant's JwtSecretProgram.cs including AddHttpMessageHandler<TenantHeaderHandler>() for all ACL HttpClientsappsettings.json with unified Tenants structureITenantResolver and TenantHeaderHandler[Fact]
public void TenantResolver_ShouldReturn_CorrectConnectionString()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
["Tenant:DefaultTenantId"] = "tenant-a",
["Tenants:tenant-a:ConnectionString"] = "Server=srv1;Database=TenantA_DB;",
["Tenants:tenant-a:JwtSecret"] = "test-secret-256bits-long-enough"
})
.Build();
var resolver = new TenantResolver(config);
// Act & Assert
Assert.Equal("tenant-a", resolver.TenantId);
Assert.Equal("Server=srv1;Database=TenantA_DB;", resolver.ConnectionString);
Assert.Equal("test-secret-256bits-long-enough", resolver.JwtSecret);
}
[Fact]
public async Task TenantHeaderHandler_ShouldAdd_TenantIdHeader()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
["Tenant:DefaultTenantId"] = "tenant-b"
})
.Build();
var handler = new TenantHeaderHandler(config)
{
InnerHandler = new TestHandler()
};
var client = new HttpClient(handler);
// Act
var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost/api/test");
await client.SendAsync(request);
// Assert — verify header was added via TestHandler capture
}
private class TestHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Assert.True(request.Headers.Contains("X-Tenant-Id"));
Assert.Equal("tenant-b", request.Headers.GetValues("X-Tenant-Id").First());
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
}
tools
Guides how to integrate the zTools package for ChatGPT, DALL-E image generation, file upload (S3), slug generation, email sending, and document validation in a .NET 8 project. Use when the user wants to use AI features, upload files, generate slugs, send emails, or understand zTools integration.
documentation
Generates a comprehensive, standardized README.md for any project. Use when the user wants to create or regenerate a README file following the project's documentation standard.
development
Create modal dialogs in the frontend using a custom Modal component built on top of Radix UI Dialog. Use this skill whenever the user asks to create, add, or modify a modal, dialog, popup, or confirmation prompt in the React application.
development
Create the complete frontend architecture for a new entity in the React application. Generates TypeScript types, service class, context provider, custom hook, and registers the provider in main.tsx. Use this skill when the user asks to create a new entity, feature module, or domain area in the frontend.