Multi-Tenant Access Control

Take the tenant from the token, enforce it in authorization and in every query, and prove with tests that one tenant cannot reach another's data.

SyntaxmodelBuilder.Entity<Order>() .HasQueryFilter("Tenant", o => o.TenantId == _tenant.TenantId) .HasQueryFilter("SoftDelete", o => !o.IsDeleted);

In a multi-tenant API one deployment and usually one database serve many customers' organisations. The worst bug it can have is a cross-tenant leak, and the defence is layered: identify the tenant from something the caller cannot forge, check it at the edge, and enforce it again where data is read and written.

Where the tenant comes from

Take it from a claim in the validated token: tid in Entra ID, or a tenant_id claim your identity provider issues. Never from an X-Tenant-Id header, a query string or a route segment alone, since the caller controls those. If the URL carries a tenant (/tenants/{tenantId}/invoices), it is a claim to be checked against the token, not a source. Resolve the tenant once per request into a scoped TenantContext and have everything downstream read that.

Enforcing it in two places

LayerMechanismCatches
EdgeFallback and default policies requiring the tenant claim (named policies must include it themselves); a handler comparing the route tenant with the tokenTokens without a tenant, URL tampering
ReadsEF Core query filter o.TenantId == _tenantIdAny query, including ones written next year
WritesA SaveChanges interceptor that stamps and checks TenantIdInserts and updates for the wrong tenant
DatabasePostgreSQL row-level securityRaw SQL, Dapper, reporting jobs

EF Core 10 adds named query filters: HasQueryFilter("Tenant", ...) and HasQueryFilter("SoftDelete", ...) can coexist on one entity, and IgnoreQueryFilters(["SoftDelete"]) turns off just one. Before named filters, an entity had one filter, and a second HasQueryFilter call replaced the first: adding soft delete could silently remove tenant isolation. Query filters do not apply to SQL you run outside EF Core.

Fail closed

A background job, a message consumer or a health check has no user, so the tenant context is empty. The filter must then match nothing, never everything: compare with a null tenant rather than skipping the filter. Jobs that legitimately span tenants should loop over tenants and set the context per tenant, or use IgnoreQueryFilters in one audited place.

Leaks outside the database

  • Cache keys (memory, Redis, output cache) without the tenant id serve tenant A's data to tenant B.
  • Blob paths, search indexes and exported files need the tenant in the key and the check.
  • Rate limit partitions per user let one tenant starve the others; partition per tenant as well.
  • Sequential ids reveal other tenants' volumes; a hidden row must return 404, as in resource-based authorization.

Testing cross-tenant access

Seed two tenants. For every endpoint, call it with tenant B's token using tenant A's ids and assert 404 (or 403 for a tampered route tenant). Run the same test for list endpoints and assert that no tenant A rows appear. Add every new endpoint to the matrix; this test is the one that catches the leak before a customer does.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 403 when a globex user calls GET /tenants/acme/invoices with a token whose tenant_id is globex
  • Keep tenant isolation on Orders while an admin restore endpoint ignores only the SoftDelete filter
  • Make a nightly export job that runs without a user see no rows instead of every tenant's orders

More examples

EF Core named filters for tenant and soft delete

The filter refers to a property of the <code>DbContext</code>, which EF Core parameterises, so each context instance uses its own request's tenant. When the context's <code>TenantId</code> is null no row can match, because the column is required, which is the fail-closed behaviour you want. Naming both filters means <code>IgnoreQueryFilters([SoftDelete])</code> cannot accidentally switch off tenant isolation, which a parameterless <code>IgnoreQueryFilters()</code> would do.

Example · csharp
using Microsoft.EntityFrameworkCore;

namespace Shop.Api.Data;

public sealed class TenantContext
{
    public string? TenantId { get; set; }
}

public sealed class Order
{
    public int Id { get; set; }
    public required string TenantId { get; set; }
    public required string Number { get; set; }
    public decimal Total { get; set; }
    public bool IsDeleted { get; set; }
}

public sealed class ShopDb(DbContextOptions<ShopDb> options, TenantContext tenant) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    // Read through a member so EF Core evaluates it per query, not once when the model is built.
    private string? CurrentTenantId => tenant.TenantId;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(order =>
        {
            order.HasIndex(o => new { o.TenantId, o.Number }).IsUnique();
            order.HasQueryFilter(TenantFilters.Tenant, o => o.TenantId == CurrentTenantId);
            order.HasQueryFilter(TenantFilters.SoftDelete, o => !o.IsDeleted);
        });
    }
}

public static class TenantFilters
{
    public const string Tenant = "Tenant";
    public const string SoftDelete = "SoftDelete";

    // Restoring a deleted order: drop the soft-delete filter only; the tenant filter still applies.
    public static Task<Order?> FindIncludingDeletedAsync(ShopDb db, int id, CancellationToken cancellationToken) =>
        db.Orders.IgnoreQueryFilters([SoftDelete]).FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}

Stamping and guarding writes with an interceptor

Query filters protect reads only; an insert carries whatever <code>TenantId</code> the code set. The interceptor stamps new rows and refuses any change where the current or original <code>TenantId</code> differs from the request's tenant, which also blocks moving a row between tenants. It overrides both <code>SavingChanges</code> and <code>SavingChangesAsync</code>, because a synchronous save never reaches the async hook. <code>ExecuteUpdate</code> and <code>ExecuteDelete</code> skip the change tracker and interceptors entirely and rely on the query filter. The interceptor is scoped and resolved inside the <code>AddDbContext</code> factory, so it sees the same <code>TenantContext</code> as the request.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Shop.Api.Data;

public interface ITenantOwned
{
    string TenantId { get; set; }
}

public sealed class TenantContext
{
    public string? TenantId { get; set; }
}

public sealed class TenantWriteGuard(TenantContext tenant) : SaveChangesInterceptor
{
    // SaveChanges and SaveChangesAsync go through different hooks; guard both.
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        Guard(eventData.Context);
        return result;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        Guard(eventData.Context);
        return ValueTask.FromResult(result);
    }

    private void Guard(DbContext? context)
    {
        if (context is null) return;

        foreach (var entry in context.ChangeTracker.Entries<ITenantOwned>())
        {
            if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue;

            var current = tenant.TenantId
                ?? throw new InvalidOperationException("Cannot save tenant data without a tenant.");

            if (entry.State == EntityState.Added && string.IsNullOrEmpty(entry.Entity.TenantId))
                entry.Entity.TenantId = current;
            else if (entry.Entity.TenantId != current
                || entry.Property(e => e.TenantId).OriginalValue != current)
                throw new InvalidOperationException($"Write to tenant data outside tenant '{current}'.");
        }
    }
}

public static class TenantDataRegistration
{
    public static IServiceCollection AddTenantData<TContext>(this IServiceCollection services, string connectionString)
        where TContext : DbContext
    {
        services.AddScoped<TenantContext>();
        services.AddScoped<TenantWriteGuard>();
        services.AddDbContext<TContext>((sp, options) => options
            .UseSqlite(connectionString)
            .AddInterceptors(sp.GetRequiredService<TenantWriteGuard>()));
        return services;
    }
}

Row-level security in PostgreSQL as a backstop

Row-level security makes the database itself refuse other tenants' rows, so a raw Dapper query or a reporting script that forgets the filter still sees only one tenant. <code>current_setting('app.tenant_id', true)</code> returns NULL or an empty string when no tenant is set, and either one matches no row. Use <code>set_config(..., true)</code> inside a transaction: it ties the tenant to the transaction that runs the queries, so one connection can serve several tenants safely, and nothing outlives the request under PgBouncer transaction pooling or with <code>No Reset On Close</code>.

Example · sql
-- Run once in a migration. The API connects as shop_app, which does not own the table.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.tenant_id', true))
    WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO shop_app;

-- Per request, inside the transaction that runs the queries.
BEGIN;
SELECT set_config('app.tenant_id', 'acme', true);
SELECT id, number, total FROM orders WHERE status = 'Placed';
COMMIT;

Discussion

  • Be the first to comment on this lesson.