A multi-tenant application allows multiple customers to use the same application while keeping their data separate. This approach is common in SaaS products, business management systems, customer portals, and subscription-based platforms.
For example, a company might use an ASP.NET Core application to manage orders, invoices, and customer records. Several businesses use the same application, but each business should see only its own information.
The challenge is that all tenants may share the same application code, database, and infrastructure. If a query forgets to filter records by tenant, one customer could access another customer's data.
In this article, we will build a practical tenant isolation approach using ASP.NET Core, Entity Framework Core, and SQL Server. We will cover tenant identification, global query filters, safe data insertion, authorization, common security mistakes, and production considerations.
What Is Multi-Tenant Architecture?
In a multi-tenant application, a tenant represents a customer, organization, or business that uses the application.
There are several common ways to store tenant data.
Architecture | How it works | Main consideration |
|---|
Shared database and shared tables | All tenants use the same tables, with a tenant identifier on each tenant-owned record | Requires strict query and write isolation |
Shared database with separate schemas | Tenants use different database schemas | Requires schema management and careful access controls |
Separate database per tenant | Each tenant has its own database | Provides stronger database separation but increases operational work |
This article uses a shared database with shared tables. It is a common design for SaaS applications, but it requires consistent enforcement of tenant boundaries.
The central rule is simple: every operation involving tenant-owned data must verify that the current tenant is allowed to access that data.
Step 1: Add a Tenant Identifier to Your Data Model
Start by adding a tenant identifier to each entity that belongs to a particular customer.
For example, an order should belong to one tenant.
public sealed class Order
{
public int Id { get; set; }
public Guid TenantId { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
}
The TenantId property identifies the organization that owns the order.
A shared database might contain records like these:
Id | TenantId | OrderNumber | TotalAmount |
|---|
1 | Tenant A | ORD-1001 | 250.00 |
2 | Tenant B | ORD-2001 | 450.00 |
3 | Tenant A | ORD-1002 | 175.00 |
Tenant A should see orders 1 and 3, while Tenant B should see only order 2.
Do not rely on the order number or record ID to provide isolation. Those values identify records, not the tenant that is authorized to access them.
For production systems, also consider a database index that supports tenant-scoped queries. If order numbers are unique only within a tenant, enforce that rule with a composite unique index rather than a global uniqueness assumption.
Step 2: Identify the Current Tenant
Before querying customer data, the application needs to determine which tenant is making the request.
Tenant identification can come from an authenticated user's organization membership, a validated domain or subdomain, or another trusted application mechanism.
For example, an application might use a tenant identifier stored in the authenticated user's claims.
The important distinction is between identifying a tenant and authorizing access to that tenant. A tenant ID supplied by a browser or request header is not proof that the user belongs to that tenant.
Create an interface that exposes the current tenant:
public interface ITenantProvider
{
Guid TenantId { get; }
}
A simple implementation can read the tenant identifier from the authenticated user's claims through the current HTTP context.
using System.Security.Claims;
public sealed class HttpTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpTenantProvider(
IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Guid TenantId
{
get
{
var user = _httpContextAccessor.HttpContext?.User;
var tenantClaim = user?.FindFirst("tenant_id")?.Value;
if (!Guid.TryParse(tenantClaim, out var tenantId))
{
throw new UnauthorizedAccessException(
"A valid tenant context is required.");
}
return tenantId;
}
}
}
This example assumes the application has already authenticated the user and that a trusted authentication process supplies the tenant claim. Do not create that claim directly from an unverified request header.
If users can belong to multiple organizations, the application must validate the selected organization against the user's actual memberships before establishing the tenant context.
Register the services in Program.cs:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpTenantProvider>();
The scoped lifetime ensures that the provider is created within the request scope. Do not store the current tenant in a static variable or a shared singleton, because concurrent requests from different tenants could interfere with one another.
Step 3: Apply Global Query Filters in Entity Framework Core
A common source of tenant data leaks is forgetting to include the tenant condition in a database query.
For example, this query is unsafe in a shared multi-tenant database:
var orders = await dbContext.Orders.ToListAsync();
It can return orders belonging to every tenant if no other restriction is applied.
Entity Framework Core supports global query filters that automatically apply a condition to queries for a particular entity type.
First, configure the DbContext to receive the current tenant provider.
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext : DbContext
{
private readonly ITenantProvider _tenantProvider;
public AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasQueryFilter(order =>
order.TenantId == _tenantProvider.TenantId);
modelBuilder.Entity<Order>()
.HasIndex(order => new
{
order.TenantId,
order.OrderNumber
})
.IsUnique();
}
}
The global query filter adds the tenant condition to normal queries against the Order entity.
Now, a query such as the following returns orders for the current tenant, assuming the context has a valid tenant provider.
var orders = await dbContext.Orders
.AsNoTracking()
.ToListAsync();
The AsNoTracking method is useful for read-only queries because EF Core does not need to track changes to the returned entities.
Global query filters reduce the chance of forgetting tenant conditions, but they are not a complete security boundary by themselves. Code can bypass filters with operations such as IgnoreQueryFilters, and raw SQL or other data access paths need separate attention.
Apply the same isolation strategy to every tenant-owned entity, not just orders. If a customer, invoice, or document belongs to a tenant, its queries must also respect tenant ownership.
Step 4: Set the Tenant ID When Saving New Records
Filtering reads is only half the job. The application must also prevent users from creating records under another tenant's identifier.
Never accept the tenant identifier for a new order directly from an untrusted request body.
Instead, assign it from the validated tenant context.
public sealed class OrderService
{
private readonly AppDbContext _dbContext;
private readonly ITenantProvider _tenantProvider;
public OrderService(
AppDbContext dbContext,
ITenantProvider tenantProvider)
{
_dbContext = dbContext;
_tenantProvider = tenantProvider;
}
public async Task<Order> CreateOrderAsync(
string orderNumber,
decimal totalAmount,
CancellationToken cancellationToken)
{
if (totalAmount < 0)
{
throw new ArgumentOutOfRangeException(
nameof(totalAmount));
}
var order = new Order
{
TenantId = _tenantProvider.TenantId,
OrderNumber = orderNumber,
TotalAmount = totalAmount
};
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(
cancellationToken);
return order;
}
}
The service determines ownership from the current tenant context rather than trusting the client to supply it.
For a production application, validate the order number and business rules as well. Consider adding a SaveChanges validation layer that checks tenant ownership on new and modified entities. This provides additional protection when multiple services or controllers can write to the same DbContext.
Step 5: Verify Tenant Ownership When Updating or Deleting Data
A record ID should never be treated as proof of access.
For example, a user might change an order ID in the URL and attempt to update an order belonging to another organization.
A tenant-filtered query helps prevent this:
public async Task<bool> UpdateOrderAsync(
int orderId,
decimal totalAmount,
CancellationToken cancellationToken)
{
var order = await _dbContext.Orders
.SingleOrDefaultAsync(
x => x.Id == orderId,
cancellationToken);
if (order is null)
{
return false;
}
order.TotalAmount = totalAmount;
await _dbContext.SaveChangesAsync(
cancellationToken);
return true;
}
Because the query uses the global filter, it normally cannot retrieve an order owned by another tenant.
Returning a not-found response for inaccessible records can also avoid revealing whether another tenant's record exists. Your API should apply a consistent policy for this behavior.
Be careful when updating entities that have already been loaded, attaching entities from request data, or executing bulk updates. Verify that the entity belongs to the current tenant and that its TenantId cannot be changed by an unauthorized operation.
Step 6: Add Database-Level Protection
Application-level checks are essential, but a database can provide another layer of isolation.
With SQL Server, Row-Level Security can restrict which rows a database user or session can access. This can help protect data if an application query accidentally omits its tenant condition.
However, database-level protection must be configured correctly. The database needs a trustworthy way to identify the current tenant, and the application must set that context safely for each connection or transaction.
Connection pooling, transaction boundaries, background jobs, and administrative operations all need careful handling. A tenant context that remains set on a reused connection could cause incorrect access decisions if it is not managed properly.
For high-sensitivity applications, consider combining EF Core query filters, tenant-aware authorization, database constraints, and SQL Server Row-Level Security.
Common Mistakes to Avoid
One of the most dangerous mistakes is trusting a tenant ID supplied by the client without checking the user's membership.
Another is applying a query filter to orders while forgetting to protect invoices, documents, or related entities. A single unfiltered query can expose data even when most endpoints are implemented correctly.
Do not allow ordinary application code to change TenantId freely during an update. Tenant ownership should normally be immutable after record creation.
Also, remember that global query filters can be bypassed. Review raw SQL, bulk operations, reporting queries, background jobs, and administrative endpoints separately.
Finally, do not assume that a random or difficult-to-guess record ID provides tenant isolation. Authorization must be enforced even when identifiers are unpredictable.
Best Practices for Production Applications
Establish tenant context from authenticated and validated information.
Apply tenant filtering to every tenant-owned entity.
Assign tenant identifiers on the server when creating records.
Prevent unauthorized changes to tenant ownership.
Use database constraints to enforce tenant-specific uniqueness rules.
Review raw SQL, bulk operations, background jobs, and reporting queries.
Test access using users from different tenants.
Consider database-level isolation for sensitive applications.
Log security failures without recording unnecessary customer data.
Review authorization whenever new endpoints or entities are introduced.
Automated tests should specifically verify that one tenant cannot read, update, or delete another tenant's records. Include attempts to change identifiers and submit forged tenant values, not just ordinary successful requests.
Advantages and Limitations
A shared-database multi-tenant architecture can reduce infrastructure duplication and simplify application deployment. Global query filters also reduce repetitive tenant conditions in ordinary EF Core queries.
However, tenants still share database infrastructure, and an incorrectly configured query or privileged data access path can create a serious data leak. Query filters alone do not replace authorization or database security.
The appropriate level of isolation depends on the application's risk, regulatory requirements, operational needs, and the sensitivity of the stored information.
Summary
Keeping customer data separate in a multi-tenant ASP.NET Core application requires more than adding a TenantId column. The application must establish a trusted tenant context, apply tenant-aware queries, assign ownership when saving records, and prevent unauthorized changes.
EF Core global query filters are a useful starting point, but they should be combined with authorization checks, database constraints, careful handling of raw queries, and automated cross-tenant security tests.
The most important rule is to verify ownership at every data access boundary. A single missing check can expose information that belongs to another customer.
Join the conversation! Your thoughts help the community grow.