Keeping tenant data strictly separated is a must for multi-tenant apps. Row-Level Security (RLS) in SQL Server provides a robust, enforced filter at the database level so that every query issued against protected tables automatically returns only rows the caller may see — independent of application bugs.

This article explains a practical production-ready design and implementation:

All headings are compact for easy scanning.

1. Goals and constraints

2. High-level architecture (text diagram)

[Angular / API clients]  -->  [ASP.NET Core API]  -->  [SQL Server - Single DB]
       |                           |                        |
       |                           |-- sets session_context -->|
       |                           |                        |
       v                           v                        v
   User requests             Controller / Middleware   RLS predicate uses
  (claims include TenantId)   sets SESSION_CONTEXT         SESSION_CONTEXT
                                                      to filter rows by TenantId

3. Core idea (brief)

  1. Add a TenantId column to every tenant-scoped table.

  2. In SQL Server create an inline table-valued function (ITVF) that returns rows allowed for the current session by comparing the row TenantId with SESSION_CONTEXT('TenantId'). Optionally allow bypass if SESSION_CONTEXT('IsAdmin') = 1.

  3. Create a security policy that attaches the predicate to the table (FILTER predicate).

  4. Ensure every DB session used by the app has correct SESSION_CONTEXT values set immediately after opening the connection. Use an EF Core DbConnectionInterceptor (or ADO.NET wrapper) to set this per connection to work with connection pools.

  5. For inserts where app may omit TenantId, use an AFTER INSERT trigger to set TenantId from SESSION_CONTEXT (optional).

4. Example schema and SQL scripts

Below uses uniqueidentifier for TenantId (recommended) — adapt to INT if you prefer.

4.1 Create a sample tenant table

CREATE TABLE dbo.Tenant (
  TenantId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
  Name NVARCHAR(200) NOT NULL
);

CREATE TABLE dbo.Customer (
  CustomerId UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
  TenantId UNIQUEIDENTIFIER NOT NULL,
  Name NVARCHAR(200) NOT NULL,
  Email NVARCHAR(200) NULL,
  CreatedOn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE INDEX IX_Customer_TenantId ON dbo.Customer(TenantId);

4.2 Inline predicate function

CREATE OR ALTER FUNCTION dbo.fn_tenant_predicate(@TenantId UNIQUEIDENTIFIER)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
(
    SELECT 1 AS allowResult
    WHERE
        -- If session context has IsAdmin = 1 allow everything
        COALESCE(CONVERT(INT, SESSION_CONTEXT(N'IsAdmin')), 0) = 1
        OR (@TenantId = CONVERT(UNIQUEIDENTIFIER, SESSION_CONTEXT(N'TenantId')))
);
GO

Notes

4.3 Apply security policy

CREATE OR ALTER SECURITY POLICY dbo.SecPolicy_Customers
ADD FILTER PREDICATE dbo.fn_tenant_predicate(TenantId) ON dbo.Customer
WITH (STATE = ON);
GO

This attaches the predicate: for every SELECT/UPDATE/DELETE the function must return a row to allow access to that row.

4.4 Optional: Insert trigger to set TenantId if omitted

CREATE OR ALTER TRIGGER dbo.trg_Customer_SetTenant
ON dbo.Customer
AFTER INSERT
AS
BEGIN
  SET NOCOUNT ON;

  UPDATE c
  SET TenantId = COALESCE(c.TenantId, TRY_CONVERT(uniqueidentifier, SESSION_CONTEXT(N'TenantId')))
  FROM dbo.Customer c
  JOIN inserted i ON c.CustomerId = i.CustomerId;
END
GO

Be cautious: triggers increase complexity — prefer application to pass TenantId explicitly.

5. How to set session context from ASP.NET Core

RLS predicate reads SESSION_CONTEXT. Because SQL connections are pooled, set the session context each time you obtain a connection. Use an EF Core DbConnectionInterceptor (or raw ADO.NET wrapper) to set it as soon as the connection opens for use.

5.1 EF Core connection interceptor (C#)

using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;

public class TenantConnectionInterceptor : DbConnectionInterceptor
{
    private readonly IHttpContextAccessor _http;

    public TenantConnectionInterceptor(IHttpContextAccessor http) => _http = http;

    public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
    {
        await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);

        var httpContext = _http.HttpContext;
        // If called from background worker, httpContext may be null; set TenantId accordingly
        var tenantClaim = httpContext?.User?.FindFirst("tenantid")?.Value;

        // Example: if the tenant id is a GUID string
        if (!string.IsNullOrEmpty(tenantClaim))
        {
            using var cmd = connection.CreateCommand();
            cmd.CommandText = "EXEC sp_set_session_context @key=N'TenantId', @value=@tid, @read_only=1;";
            var p1 = cmd.CreateParameter(); p1.ParameterName = "@tid"; p1.Value = tenantClaim;
            cmd.Parameters.Add(p1);
            await cmd.ExecuteNonQueryAsync(cancellationToken);
        }

        // If user is global admin set IsAdmin = 1
        var isAdmin = httpContext?.User?.IsInRole("GlobalAdmin") == true ? 1 : 0;
        using var cmd2 = connection.CreateCommand();
        cmd2.CommandText = "EXEC sp_set_session_context @key=N'IsAdmin', @value=@isadmin, @read_only=1;";
        var p2 = cmd2.CreateParameter(); p2.ParameterName = "@isadmin"; p2.Value = isAdmin;
        cmd2.Parameters.Add(p2);
        await cmd2.ExecuteNonQueryAsync(cancellationToken);
    }

    // Also override ConnectionOpened for sync calls if needed
}

Register interceptor in Program.cs:

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<DbConnectionInterceptor, TenantConnectionInterceptor>();
builder.Services.AddDbContext<MyDbContext>((sp, opts) =>
{
    opts.UseSqlServer(connString);
    // EF Core will pick up registered interceptors automatically
});

Notes

5.2 ADO.NET / Dapper approach

When you open a SqlConnection, run:

await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = "EXEC sp_set_session_context @key=N'TenantId', @value=@tid, @read_only=1;";
cmd.Parameters.AddWithValue("@tid", tenantIdString);
await cmd.ExecuteNonQueryAsync();

Always set before running any queries.

6. Insert / Update / Delete considerations

7. Global admin & emergency cross-tenant access

8. Testing and verification

  1. Connect as Tenant A (set TenantId session context), SELECT * FROM Customer → must only show Tenant A rows.

  2. Connect as Tenant B → only Tenant B rows.

  3. Connect as GlobalAdmin (IsAdmin=1) → see all rows.

  4. Try UPDATE Customer SET Name='X' WHERE TenantId <> <current> as Tenant A → should not affect other tenant rows.

  5. Confirm sp_set_session_context with @read_only=1 prevents later modification of the context on that session.

Helpful queries

-- show policy
SELECT * FROM sys.security_policies;

-- show predicates
SELECT * FROM sys.security_predicates;

-- show session context value
SELECT SESSION_CONTEXT(N'TenantId'), SESSION_CONTEXT(N'IsAdmin');

9. Performance and indexing

10. Migration pattern for existing DB

  1. Add TenantId column (nullable initially) to tables.

  2. Backfill TenantId values for existing rows (requires mapping logic).

  3. Add NOT NULL constraint (once backfilled) and add indexes.

  4. Create predicate function and security policy in a maintenance window. Test thoroughly.

  5. Update application code to always set TenantId in inserts and to use the interceptor.

  6. Remove any application-level tenant filters to avoid double filtering.

11. Audit, logging and monitoring

12. Security notes & best practices

13. Troubleshooting common issues

14. Example: Putting it all together (end-to-end steps)

  1. Add TenantId to tables, index it.

  2. Build fn_tenant_predicate to check SESSION_CONTEXT('TenantId') and IsAdmin.

  3. Create SECURITY POLICY with FILTER predicate on each tenant scoped table.

  4. Implement EF Core DbConnectionInterceptor to call sp_set_session_context for TenantId & IsAdmin when connection opens.

  5. Update app to always include tenant claim in authentication token (JWT) and populate HttpContext claims.

  6. Test as tenant, as global admin, and for background jobs.

15. Quick checklist before production