Skip to main content
SEMastery
Data Accessintermediate

How to Implement Multitenancy in ASP.NET Core with EF Core

A simple, student-friendly guide to building multitenant apps in ASP.NET Core with EF Core using tenant resolution, global query filters, and per-tenant databases.

12 min readUpdated September 23, 2025

How to Implement Multitenancy in ASP.NET Core with EF Core

Think about a big apartment building in your city. There is one front gate, one watchman, one set of pipes, and one electricity line for the whole building. But inside, each family lives in their own flat. Family on the second floor cannot walk into the flat on the fifth floor. Each family has their own key. Each family has their own things.

A multitenant application works just like that building. One app runs for everyone. But each customer (we call them a tenant) gets their own private space. The Sharma family's data and the Khan family's data live in the same building, but they can never see each other's rooms.

In this guide we will build this "apartment building" for software using ASP.NET Core and EF Core. We will keep the language simple. If you know a little C# and a little EF Core, you can follow along. By the end you will know how to find out which tenant is asking, how to keep their data separate, and how to do it safely.

What is a tenant?

A tenant is one customer of your app. If you build a school management app and sell it to 50 schools, then each school is a tenant. Greenwood School is one tenant. Sunrise School is another tenant. They use the same website, the same code, the same server. But Greenwood must never see Sunrise's students.

So the big job of multitenancy is separation. Each tenant's data must stay in its own flat.

One app serving three separate tenants

Three ways to separate tenant data

There are three common ways to keep tenant data apart. They differ in how strongly they isolate the data, and how much they cost to run. Microsoft Learn describes the same three choices in its EF Core multitenancy guide.

ApproachHow it worksIsolationCost
Shared database, TenantId columnAll tenants share one database and the same tables. Each row has a TenantId column.LowestLowest
Shared database, schema per tenantOne database, but each tenant gets its own schema (set of tables).MediumMedium
Database per tenantEach tenant gets a whole separate database.HighestHighest

Let us picture the three side by side.

Three ways to store multitenant data

Most apps should start with the shared database and a TenantId column. It is the simplest and cheapest. You only move to a separate database when a customer has a strong reason, like a legal rule about where their data must live, or a need for their own backups. We will build the shared approach in full, then show the database-per-tenant approach near the end.

The whole flow at a glance

Before we write code, let us see the full journey of a single web request in a multitenant app. The most important step is finding out which tenant is asking. We call this tenant resolution.

A request in a multitenant app

Request
Resolve tenant
Set TenantId
Query DB
Filtered data

Steps

1

Request

User hits the API

2

Resolve tenant

Read host or header

3

Set TenantId

Store in a scoped service

4

Query DB

EF Core adds filter

5

Filtered data

Only this tenant's rows

From the browser all the way to filtered data

Step 1: Find out who the tenant is

Every request must tell us which tenant it belongs to. There are a few common ways to know:

  • Subdomain: greenwood.myapp.com means tenant "greenwood".
  • HTTP header: the request carries a header like X-Tenant-Id: greenwood.
  • Logged-in user: the user's login token (claims) says which tenant they belong to.

We will keep a small service that holds the current tenant for one request. In ASP.NET Core, each web request is its own scope. So a scoped service is the perfect place to keep the tenant.

// Holds the tenant for the current request.
public interface ITenantProvider
{
    string? TenantId { get; }
    void SetTenant(string tenantId);
}
 
public class TenantProvider : ITenantProvider
{
    public string? TenantId { get; private set; }
 
    public void SetTenant(string tenantId) => TenantId = tenantId;
}

Now we need something that reads the request and fills this service. A middleware is a good place. Middleware runs early, before your controllers, so the tenant is ready by the time your code needs it.

// Reads the tenant from a header and stores it for the request.
public class TenantResolverMiddleware
{
    private readonly RequestDelegate _next;
 
    public TenantResolverMiddleware(RequestDelegate next) => _next = next;
 
    public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
    {
        if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantId))
        {
            tenantProvider.SetTenant(tenantId.ToString());
        }
 
        await _next(context);
    }
}

Here is how the resolution step decides the tenant.

How the middleware picks the tenant

Step 2: Add a TenantId to your data

In the shared-database approach, every tenant-owned table needs a TenantId column. A clean trick is to make an interface that marks an entity as "owned by a tenant".

// Any entity that belongs to a tenant implements this.
public interface ITenantOwned
{
    string TenantId { get; set; }
}
 
public class Student : ITenantOwned
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string TenantId { get; set; } = string.Empty;
}

Step 3: Filter every query automatically

Now comes the most important part. We must make sure every query only returns rows for the current tenant. If we wrote the Where(s => s.TenantId == current) check by hand in every place, sooner or later we would forget one. That single forgotten filter could leak one school's data to another. That is the scariest bug in a multitenant app.

EF Core has a clean fix called a global query filter. You set the rule once in OnModelCreating, and EF Core adds it to every query for that entity, all by itself.

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;
 
    public AppDbContext(DbContextOptions<AppDbContext> options,
                        ITenantProvider tenantProvider) : base(options)
    {
        _tenantProvider = tenantProvider;
    }
 
    public DbSet<Student> Students => Set<Student>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Read the tenant once; the filter uses it on every query.
        modelBuilder.Entity<Student>()
            .HasQueryFilter(s => s.TenantId == _tenantProvider.TenantId);
    }
 
    // Stamp the TenantId on new rows automatically.
    public override int SaveChanges()
    {
        StampTenant();
        return base.SaveChanges();
    }
 
    public override Task<int> SaveChangesAsync(CancellationToken token = default)
    {
        StampTenant();
        return base.SaveChangesAsync(token);
    }
 
    private void StampTenant()
    {
        var tenantId = _tenantProvider.TenantId;
        if (tenantId is null) return;
 
        foreach (var entry in ChangeTracker.Entries<ITenantOwned>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.TenantId = tenantId;
            }
        }
    }
}

Two nice things happen here. First, the query filter hides other tenants' rows on every read. Second, the SaveChanges override stamps the right TenantId on every new row, so you never forget to set it when adding data.

Read and write are both protected

Read query
Filter applied
Write data
Stamp TenantId
Saved safely

Steps

1

Read query

context.Students

2

Filter applied

WHERE TenantId = me

3

Write data

Add new student

4

Stamp TenantId

Set on SaveChanges

5

Saved safely

Row tagged correctly

The filter guards reads; the stamp guards writes

Step 4: Wire everything up in Program.cs

Now we connect the parts in the app's startup. We register the tenant provider as scoped (one per request), add the DbContext, and plug in the middleware.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
 
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
builder.Services.AddControllers();
 
var app = builder.Build();
 
// Resolve the tenant before controllers run.
app.UseMiddleware<TenantResolverMiddleware>();
 
app.MapControllers();
app.Run();

One detail worth knowing: because the DbContext now depends on a per-request tenant, you should not use AddDbContextPool. Pooling reuses context objects across requests, and that would mix up tenants. A normal AddDbContext is the safe choice here.

A quick example controller

With all the wiring done, your controllers stay clean. You never write a tenant Where clause. The filter does it for you.

[ApiController]
[Route("api/students")]
public class StudentsController : ControllerBase
{
    private readonly AppDbContext _db;
 
    public StudentsController(AppDbContext db) => _db = db;
 
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        // Only returns the current tenant's students. No manual filter!
        var students = await _db.Students.ToListAsync();
        return Ok(students);
    }
 
    [HttpPost]
    public async Task<IActionResult> Add(string name)
    {
        var student = new Student { Name = name };
        _db.Students.Add(student);     // TenantId stamped on save
        await _db.SaveChangesAsync();
        return Ok(student);
    }
}

When you need a separate database per tenant

Sometimes a shared table is not enough. A bank customer may demand their own database. Or a law may say data must be backed up on its own. For these cases you give each tenant its own database. The tables look the same, only the connection string changes.

The trick is to pick the right connection string based on the current tenant. A simple way is to map each tenant to a connection string in configuration, then build the context with that string.

public class TenantConnectionResolver
{
    private readonly IConfiguration _config;
 
    public TenantConnectionResolver(IConfiguration config) => _config = config;
 
    public string GetConnectionString(string tenantId)
    {
        // e.g. ConnectionStrings:Tenant_greenwood in appsettings.json
        var key = $"Tenant_{tenantId}";
        return _config.GetConnectionString(key)
               ?? throw new InvalidOperationException($"No DB for tenant {tenantId}");
    }
}

Then you configure the DbContext to use the resolved string per request:

builder.Services.AddScoped<TenantConnectionResolver>();
 
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    var tenant = sp.GetRequiredService<ITenantProvider>().TenantId ?? "default";
    var resolver = sp.GetRequiredService<TenantConnectionResolver>();
    options.UseSqlServer(resolver.GetConnectionString(tenant));
});

Here is how the two big strategies compare for everyday decisions.

QuestionShared DB + TenantIdDatabase per tenant
Easy to add a new tenant?Yes, just a new TenantIdNeed to create a new database
Cheap to run many tenants?YesGets costly fast
Strong isolation for one big client?WeakerVery strong
Run a migration once for all?YesMust run on each database
Easy custom backup per tenant?HarderEasy
Choosing your approach by tenant count and isolation need

Common mistakes to avoid

Multitenancy is easy to get wrong in small ways that cause big leaks. Watch out for these:

  • Forgetting the filter on a new entity. The query filter only applies to entities you set it on. If you add a new table and forget HasQueryFilter, that table leaks. A tidy fix is to loop over all ITenantOwned types in OnModelCreating and apply the filter to each one.
  • Using IgnoreQueryFilters() carelessly. This turns off the tenant filter for a query. Only use it for admin tasks, and double-check who is allowed to call it.
  • Pooling the DbContext. As noted, AddDbContextPool does not fit a per-request tenant. Stick to AddDbContext.
  • Trusting the client too much. If you read the tenant from a header, a bad user could send a fake one. Always check the tenant against the logged-in user's claims, so a user cannot jump into another tenant.
  • Migrations across many databases. With database-per-tenant, remember each database needs its own migration run. Build a small loop or script for this.

A note on libraries

You can write all of this yourself, as we did above. There are also full frameworks that handle tenant resolution, per-tenant stores, and database routing for you, such as Finbuckle.MultiTenant. Using a library can save time once your needs grow. For learning and for small to medium apps, the hand-written approach in this guide is clear and gives you full control. Build your own first, so you truly understand what a library would be doing for you.

It is also good to keep an eye on licensing when you choose libraries in the .NET world. Some popular packages, such as MediatR and MassTransit, have moved to commercial licenses. That does not affect the EF Core multitenancy code here, but it is a habit worth keeping: always check a library's license before you depend on it.

Quick recap

  • A tenant is one customer of your app. Multitenancy keeps each tenant's data private, like flats in one building.
  • There are three ways to separate data: shared database with a TenantId, schema per tenant, and database per tenant. Start with the shared TenantId approach.
  • Every request must do tenant resolution: find out which tenant is asking, using a subdomain, a header, or the user's login claims.
  • Keep the current tenant in a scoped service, filled by a middleware that runs before your controllers.
  • Use an EF Core global query filter so every read only returns the current tenant's rows. You never write the filter by hand.
  • Override SaveChanges to stamp the right TenantId on every new row, so writes are safe too.
  • Do not use AddDbContextPool with a per-request tenant. Use plain AddDbContext.
  • For strong isolation, switch to database per tenant by choosing the connection string based on the current tenant.
  • The biggest danger is a forgotten filter that leaks one tenant's data. Let EF Core do the filtering so you cannot forget.

References and further reading

Related Posts