🏢 Building a Secure Multi-Tenant .NET 8 API with JWT Authentication and Dynamic Connection Strings

I'm a highly motivated and experienced developer expertise in leveraging the power of .NET Core Technology. Currently collaborating with an Australian company based in Nusa Dua, Bali, Indonesia, to deliver innovative application development services that push the boundaries of what technology can achieve, and also contribute to the ever-evolving landscape of the global IT industry
In this guide, we’ll build a multi-tenant .NET 8 Web API where each tenant (for example, AWX and NSW) has its own SQL Server database, and authentication is handled securely via JWT tokens that include the tenantId claim.
🧠 Why Multi-Tenancy?
Imagine you’re building a SaaS app serving multiple organizations each wants their data isolated in their own database, but you want to maintain a single codebase.
This is where multi-tenancy comes in.
Each tenant will have:
its own connection string
its own users and data
secure access via JWT claims
🧩 Project Overview
We’ll implement:
JWT Authentication
Dynamic tenant database switching
Tenant Middleware
Database Seeding
Migration-ready EF Core setup
Example tenants
| Tenant ID | Database Name | Connection String |
| AWX | Tenant_AWX | Server=.;Database=Tenant_AWX;Trusted_Connection=True; |
| NSW | Tenant_NSW | Server=.;Database=Tenant_NSW;Trusted_Connection=True; |
⚙️ Step 1. Install Required Packages
Run the following NuGet commands:
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Swashbuckle.AspNetCore
🏗️ Step 2. Define Models
Models/Tenant.cs
public class Tenant
{
public int Id { get; set; }
public string TenantId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ConnectionString { get; set; } = string.Empty;
}
Models/User.cs
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
}
🗄️ Step 3. Setup the AppDbContext
Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using MultiTenantApi.Models;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
}
🧩 Step 4. Tenant Database Factory
We use this to dynamically switch the database based on tenant ID.
Data/ITenantDbContextFactory.cs
public interface ITenantDbContextFactory
{
AppDbContext CreateDbContext(string tenantId);
}
Data/TenantDbContextFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
public class TenantDbContextFactory : ITenantDbContextFactory
{
private readonly IConfiguration _config;
public TenantDbContextFactory(IConfiguration config)
{
_config = config;
}
public AppDbContext CreateDbContext(string tenantId)
{
var connectionString = _config.GetConnectionString(tenantId)
?? throw new Exception($"Connection string for tenant '{tenantId}' not found.");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.Options;
return new AppDbContext(options);
}
}
🧱 Step 5. Middleware for Tenant Resolution
This middleware reads the tenant ID from JWT claims and makes it available for other services.
Middleware/TenantMiddleware.cs
using System.Security.Claims;
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.User.FindFirstValue("tenantId");
if (!string.IsNullOrEmpty(tenantId))
{
context.Items["TenantId"] = tenantId;
}
await _next(context);
}
}
public static class TenantMiddlewareExtensions
{
public static IApplicationBuilder UseTenantResolver(this IApplicationBuilder app)
{
return app.UseMiddleware<TenantMiddleware>();
}
}
🔐 Step 6. Authentication Services
Services/IUserService.cs
public interface IUserService
{
Task<bool> IsValidUserAsync(string username, string password, string tenantId);
string GenerateJWTToken(string username, string tenantId);
}
Services/UserService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Text;
public class UserService : IUserService
{
private readonly ITenantDbContextFactory _factory;
private readonly IConfiguration _config;
public UserService(ITenantDbContextFactory factory, IConfiguration config)
{
_factory = factory;
_config = config;
}
public async Task<bool> IsValidUserAsync(string username, string password, string tenantId)
{
using var db = _factory.CreateDbContext(tenantId);
var user = await db.Users.FirstOrDefaultAsync(u =>
u.Username == username && u.Password == password && u.TenantId == tenantId);
return user != null;
}
public string GenerateJWTToken(string username, string tenantId)
{
var jwtKey = _config["Jwt:Key"];
var issuer = _config["Jwt:Issuer"];
var audience = _config["Jwt:Audience"];
var claims = new[]
{
new Claim(ClaimTypes.Name, username),
new Claim("tenantId", tenantId)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
🧰 Step 7. Auth Controller
Controllers/AuthController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IUserService _userService;
public AuthController(IUserService userService)
{
_userService = userService;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest model)
{
var isValid = await _userService.IsValidUserAsync(model.Username, model.Password, model.TenantId);
if (!isValid)
throw new Exception("User Not Found");
var token = _userService.GenerateJWTToken(model.Username, model.TenantId);
return Ok(new { token });
}
}
public record LoginRequest(string Username, string Password, string TenantId);
🌱 Step 8. Seed Data for Tenants and Users
Data/AppDbContext.cs (add inside OnModelCreating)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Tenant>().HasData(
new Tenant { Id = 1, TenantId = "AWX", Name = "AWX Org", ConnectionString = "Server=.;Database=Tenant_AWX;Trusted_Connection=True;" },
new Tenant { Id = 2, TenantId = "NSW", Name = "NSW Org", ConnectionString = "Server=.;Database=Tenant_NSW;Trusted_Connection=True;" }
);
modelBuilder.Entity<User>().HasData(
new User { Id = 1, Username = "john.awx", Password = "Password0", TenantId = "AWX" },
new User { Id = 2, Username = "mary.nsw", Password = "Password0", TenantId = "NSW" }
);
}
🚀 Step 9. Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var jwtConfig = builder.Configuration.GetSection("Jwt");
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtConfig["Issuer"],
ValidAudience = jwtConfig["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig["Key"]!))
};
});
builder.Services.AddAuthorization();
builder.Services.AddScoped<ITenantDbContextFactory, TenantDbContextFactory>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("AWX"))); // for migration
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.UseTenantResolver();
app.MapControllers();
app.Run();
⚡ Step 10. appsettings.json
{
"ConnectionStrings": {
"AWX": "Server=.;Database=Tenant_AWX;Trusted_Connection=True;TrustServerCertificate=True;",
"NSW": "Server=.;Database=Tenant_NSW;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Jwt": {
"Key": "SuperSecretKeyForJwt",
"Issuer": "MultiTenantApi",
"Audience": "MultiTenantApiUsers"
}
}
🧪 Testing
Login Request:
POST /api/auth/login
Content-Type: application/json
{
"username": "john",
"password": "123",
"tenantId": "AWX"
}
Response:
{
"token": "eyJhbGciOiJIUzI1NiIs..."
}
This token includes "tenantId": "AWX" in its claims — and the middleware ensures all requests for that token connect to AWX’s database.
✅ Summary
| Concept | Description |
| TenantDbContextFactory | Creates DbContext dynamically using the correct tenant connection string. |
| TenantMiddleware | Extracts tenantId from JWT and injects into request context. |
| Seed Data | Provides demo tenants and users for testing. |
| JWT Authentication | Ensures each user logs in and receives a token tied to their tenant. |
💡 Final Thoughts
This architecture is scalable, secure, and migration-friendly — perfect for SaaS systems serving multiple organizations.
Each tenant remains logically isolated, yet the API maintains one unified codebase.






