The deliverable is the test, not the policy. The value is an assertion that goes red when isolation breaks, because every way it breaks here is silent: no error, no warning, no log line, and a catalog view that still reports the policy enabled.
Measured 2026-08-28 against a live engine reporting EngineEdition 5 and Edition SQL Azure.
Open the measured runs when you need the
statement behind a count or a message number below, or before changing one.
1. A filter predicate is not the boundary
Microsoft Learn states it plainly: the application can INSERT rows, even if they will be filtered
during any other operation. Measured as a low-privilege user holding tenant id 1, filter predicate
only:
| Statement | Result | What it cost |
|---|---|---|
INSERT a row stamped tenant_id = 2 |
succeeded | the row exists, invisible to its author, visible to tenant 2 |
UPDATE one of my own rows to tenant_id = 2 |
succeeded | my row moved into another tenant, silently |
UPDATE a row of tenant 2 |
0 rows affected | the filter protects the read behind a write |
DELETE a row of tenant 2 |
0 rows affected | same |
INSERT a key that already exists in tenant 2 |
Msg 2627 |
the key space of another tenant is probeable |
The first two rows are the leak. The application that wrote them can never read them back and never delete them, because the filter now hides them from their own author. Recovery meant disabling the policy.
The last row is separate and smaller. A filter predicate hides rows, not their existence: Msg 2627
on a duplicate key and success on a free one let a caller enumerate another tenant's keys one
attempt at a time. Use identifiers with no guessable ordering.
2. The block predicate, and why it is two predicates
Learn defines four block operations: AFTER INSERT, AFTER UPDATE, BEFORE UPDATE and
BEFORE DELETE. Adding the first two turned both cross-tenant writes into:
Msg 33504: The attempted operation failed because the target object '<db>.<schema>.<table>'
has a block predicate that conflicts with this operation.
The legitimate write into the caller's own tenant still succeeded.
AFTER UPDATE is the one people leave out. Measured: with the filter predicate and
AFTER INSERT in place but no AFTER UPDATE, the cross-tenant insert was refused and the
tenant-move update still succeeded. One row left the tenant with no error.
CREATE FUNCTION sec.fn_tenant(@tenant_id int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS ok
WHERE @tenant_id = CAST(SESSION_CONTEXT(N'tenant_id') AS int);
GO
CREATE SECURITY POLICY sec.p_orders
ADD FILTER PREDICATE sec.fn_tenant(tenant_id) ON dbo.orders,
ADD BLOCK PREDICATE sec.fn_tenant(tenant_id) ON dbo.orders AFTER INSERT,
ADD BLOCK PREDICATE sec.fn_tenant(tenant_id) ON dbo.orders AFTER UPDATE
WITH (STATE = ON);
GO
-- the other half of the guarantee: the application never rewrites a tenant id
DENY UPDATE ON dbo.orders(tenant_id) TO app_user;
Learn adds two things. The optimizer skips an AFTER UPDATE block predicate when the update changed
no column the predicate reads, so ordinary updates pay nothing for it. And Learn's middle-tier
example omits AFTER UPDATE only because it denies UPDATE on the tenant column instead. Take
both. BEFORE UPDATE and BEFORE DELETE are redundant while a filter predicate is present, since
the filter has already removed the other tenant's rows from the statement.
3. How the tenant reaches the predicate, and what pooling does to it
Two designs, and the difference is measurable rather than stylistic.
SESSION_CONTEXT is a variable, not an identity
Learn is explicit that any user can set a session context for their session, and the measurement
matches: a user granted only CONNECT plus SELECT, INSERT, UPDATE and DELETE on one table
set its own tenant id to 2 and read tenant 2's rows. No grant was needed, and none can be revoked
to stop it.
So a SESSION_CONTEXT design is exactly as strong as the guarantee that nothing
attacker-influenced ever reaches that connection as SQL. prevent-sql-injection owns that:
open it whenever any part of a statement here is assembled from input instead of passed as a
parameter, because under this design a successful injection rewrites the tenant boundary itself.
The failure that reaches production
The tenant id outlives the request, because the connection does. Measured over a five connection pool, 200 concurrent requests alternating between two tenants:
| Application shape | Cross-tenant reads in 200 requests |
|---|---|
| Set the context in one pooled request, run the query in another | 103, then 91 on a repeat |
| Set the context and run the query on one acquired connection | 0 |
| A database user per tenant, one pool per tenant, no session context | 0 |
Half the requests returned another tenant's rows and no caller saw an error. The application set the tenant on every request; it handed the set and the query to the pool separately, and the pool gave them different connections. Two rules follow:
- Set the tenant and read on the same acquired connection, in one batch, one transaction, or one explicitly held connection. Not two calls to a pool.
- Never leave the tenant set when a connection returns to the pool. That failure is open, not closed: the next caller that forgets reads the previous tenant's rows.
@read_only = 1 looks like the fix, and here the documentation and the measurement point different
ways. Learn presents it as preventing the value changing again until the connection returns to the
pool, which is exactly what it does. Measured on a reused pooled connection, the next tenant's
correct attempt to claim its own id was refused with Msg 15664 while the request that forgot
to set anything still read the previous rows. It is safe only where the connection ends with the
request.
A database user per tenant
The predicate reads USER_NAME() or DATABASE_PRINCIPAL_ID() instead of session state, resolved
through a mapping table:
CREATE FUNCTION sec.fn_tenant_by_user(@tenant_id int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS ok FROM sec.tenant_map m
WHERE m.db_user = USER_NAME() AND m.tenant_id = @tenant_id;
Measured: setting SESSION_CONTEXT under this predicate changed nothing, and borrowing another
tenant's identity with EXECUTE AS USER was refused with Msg 15517, since impersonation is a
permission the tenant does not hold. Connection pools are keyed by connection string, so each
tenant gets its own pool and there is no shared connection to leak through.
The cost is one login, one database user, one connection string and one pool per tenant, which fits hundreds of tenants and not tens of thousands. Choose it when isolation has to survive an application bug rather than depend on its absence.
4. Who bypasses the policy
| Principal | Reads through the policy | Can turn it off |
|---|---|---|
| A plain application user | no, 0 rows with no tenant set | no, Msg 33268 |
| The table owner, which is also the database owner | no, 0 rows with no tenant set | yes |
A member of db_owner |
no, 0 rows with no tenant set | yes, in one statement, then reads every row |
ALTER ANY SECURITY POLICY alone |
no | no, Msg 33268 |
ALTER on the schema holding the policy alone |
no | no, Msg 33268 |
| Both of the above together | no | yes, in one statement, then reads every row |
Ownership is not a read-through. Learn says a dbo user, a db_owner member and the table
owner are all filtered or blocked as the policy defines, and all three returned zero rows with no
tenant set. What they hold is the ability to disable, a separate and auditable statement.
ALTER ANY SECURITY POLICY alone is not enough. Learn splits the requirement, altering a policy
against that permission and creating or dropping one against ALTER on the schema. Measured,
disabling one needed both: either alone failed with Msg 33268, reported as a missing object rather
than a permission failure. Grant them as a pair, to a principal no application connects as.
5. A policy can be present and not enforcing, in three ways
All three look healthy from the application: rows come back, nothing errors.
| Shape | What the application sees | What gives it away |
|---|---|---|
WITH (STATE = OFF) |
every tenant's rows | sys.security_policies.is_enabled = 0 |
| A predicate that returns a row when the tenant is unset | every tenant's rows, to a session that set nothing | nothing in any catalog view; is_enabled is still 1 |
| A second tenant-scoped table nobody added to the policy | that table unfiltered, while the covered table is correct | no predicate rows for that object |
A predicate of the form WHERE @tenant_id = <context> OR <context> IS NULL returned all four rows
to a session with no tenant set, while every catalog view reported a healthy enabled policy. Predicates fail closed or they are not predicates. Never add
an escape clause for administrative sessions.
The third is a sweep, not a review. Run it after every migration that adds a table:
SELECT QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name) AS table_name,
SUM(CASE WHEN sp.predicate_type = 0 THEN 1 ELSE 0 END) AS filter_predicates,
SUM(CASE WHEN sp.predicate_type = 1 THEN 1 ELSE 0 END) AS block_predicates,
MIN(CAST(pol.is_enabled AS int)) AS policy_enabled
FROM sys.tables t
JOIN sys.columns c
ON c.object_id = t.object_id AND c.name = N'tenant_id'
LEFT JOIN sys.security_predicates sp ON sp.target_object_id = t.object_id
LEFT JOIN sys.security_policies pol ON pol.object_id = sp.object_id
GROUP BY t.schema_id, t.name
HAVING SUM(CASE WHEN sp.predicate_type = 1 THEN 1 ELSE 0 END) < 2
OR MIN(CAST(pol.is_enabled AS int)) IS NULL
OR MIN(CAST(pol.is_enabled AS int)) = 0;
predicate_type is 0 for filter and 1 for block, read back from sys.security_predicates
rather than recalled. Read the list rather than count it: a tenant mapping table carries a
tenant_id column, legitimately has no predicate, and is dismissed once. Anything else in it is a
tenant table that is not isolated. Verified by planting a failure: drop one block predicate from a
correct table and that table appears.
One shape the sweep misses, documented by Learn rather than measured here: predicates are not replicated to a system-versioned table's history table, which needs its own predicate by name.
6. The local rehearsal gap for retrieval
A tenant predicate on a chunk table is the cheapest defence against a retrieval answering one
tenant with another tenant's text, and on the Azure SQL Database container it cannot be rehearsed
on an indexed table. Reproduced on 300 rows with a real vector index, in both orders: index first
then CREATE SECURITY POLICY gives Msg 37579, policy first then CREATE VECTOR INDEX gives
Msg 42244. On Azure SQL Database the two coexist. So the isolation control is exactly the part of a
retrieval design that never runs locally beside the index it will meet in production.
What does work locally: the filter predicate applies correctly to exact search over
VECTOR_DISTANCE with no vector index, and two tenants over 300 chunks each got back only their own
rows. Rehearse the predicate unindexed, keep index creation out of the local path, and run the
isolation test again once the index exists on the cloud database. The same local/cloud pair was
measured for both outcomes.
Check it worked
Three checks, cheapest first, against the reference fixture: dbo.orders holding four rows, two
each for tenants 1 and 2, and app_user granted only CONNECT and the four table permissions.
One: the filtered count is smaller than the table, and the administrator is not exempt. Run it on an administrative connection, which is the only one that can impersonate:
EXEC sys.sp_set_session_context @key = N'tenant_id', @value = 1;
SELECT COUNT(*) AS admin_tenant_1 FROM dbo.orders;
EXECUTE AS USER = 'app_user';
SELECT COUNT(*) AS app_tenant_1 FROM dbo.orders;
EXEC sys.sp_set_session_context @key = N'tenant_id', @value = 2;
SELECT COUNT(*) AS app_tenant_2 FROM dbo.orders;
EXEC sys.sp_set_session_context @key = N'tenant_id', @value = NULL;
SELECT COUNT(*) AS app_no_tenant FROM dbo.orders;
REVERT;
Expected against four seeded rows: 2, 2, 2, 0, and the two twos are different rows. A 4
anywhere means the policy is not enforcing, which is section 5; zero everywhere means the predicate
is not reading the key you set.
Two: a cross-tenant write raises rather than vanishing.
EXECUTE AS USER = 'app_user';
EXEC sys.sp_set_session_context @key = N'tenant_id', @value = 1;
INSERT INTO dbo.orders (order_id, tenant_id, customer, amount) VALUES (901, 2, N'probe', 1.00);
REVERT;
Expect Msg 33504. If it succeeds you have the leak in section 1, and the row is already invisible
to the connection that wrote it.
Three: the isolation test, as the application's own user, on every deployment, saved as
tenant-isolation.sql:
SET NOCOUNT ON;
DECLARE @mine int = 1, @theirs int = 2, @fail int = 0, @n int;
-- unique per run: a failing run strands a probe row it can no longer see or delete
DECLARE @probe int = 900000 + ABS(CHECKSUM(NEWID())) % 90000;
EXEC sp_set_session_context @key = N'tenant_id', @value = 1;
-- 1. every visible row is mine
SELECT @n = COUNT(*) FROM dbo.orders WHERE tenant_id <> @mine;
IF @n <> 0 BEGIN PRINT 'FAIL 1: the filter predicate leaks another tenant''s rows'; SET @fail = 1; END
-- 2. an unfiltered aggregate cannot see more than the filtered set
SELECT @n = COUNT(*) FROM dbo.orders;
IF @n <> (SELECT COUNT(*) FROM dbo.orders WHERE tenant_id = @mine)
BEGIN PRINT 'FAIL 2: aggregate disagrees with the filtered set'; SET @fail = 1; END
-- 3. a write addressed to another tenant is REFUSED, not silently accepted
BEGIN TRY
INSERT INTO dbo.orders (order_id, tenant_id, customer, amount)
VALUES (@probe, @theirs, N'probe', 1.00);
PRINT 'FAIL 3: cross-tenant INSERT accepted, no BLOCK PREDICATE AFTER INSERT';
SET @fail = 1;
END TRY
BEGIN CATCH
IF ERROR_NUMBER() <> 33504 BEGIN PRINT CONCAT('FAIL 3: unexpected error ', ERROR_NUMBER()); SET @fail = 1; END
END CATCH
-- 4. moving one of my own rows into another tenant is REFUSED
INSERT INTO dbo.orders (order_id, tenant_id, customer, amount) VALUES (@probe + 1, @mine, N'probe', 1.00);
BEGIN TRY
UPDATE dbo.orders SET tenant_id = @theirs WHERE order_id = @probe + 1;
PRINT 'FAIL 4: tenant-move UPDATE accepted, no BLOCK PREDICATE AFTER UPDATE';
SET @fail = 1;
END TRY
BEGIN CATCH
IF ERROR_NUMBER() <> 33504 BEGIN PRINT CONCAT('FAIL 4: unexpected error ', ERROR_NUMBER()); SET @fail = 1; END
END CATCH
DELETE FROM dbo.orders WHERE order_id = @probe + 1;
-- 5. no tenant set means no rows: the predicate fails closed
EXEC sp_set_session_context @key = N'tenant_id', @value = NULL;
SELECT @n = COUNT(*) FROM dbo.orders;
IF @n <> 0 BEGIN PRINT 'FAIL 5: the predicate fails OPEN when no tenant is set'; SET @fail = 1; END
IF @fail = 1 THROW 50001, 'tenant isolation test FAILED', 1;
PRINT 'tenant isolation: ALL PASS';
export SQLCMDPASSWORD='<app-user-password>'
sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U app_user -C \
-b -m-1 -V 16 -i tenant-isolation.sql -o tenant-isolation.out
echo "exit $?"
grep -nE '^FAIL |^Msg ' tenant-isolation.out
Expect exit 0, tenant isolation: ALL PASS in the file, and no output from grep. THROW sets
severity 16, above the severity 10 -b ignores, so a failed assertion does set a non-zero exit;
-m-1 puts every message in the file, including the PRINT naming which assertion went red.
-m-1 is an ODBC sqlcmd instruction, the 18.x build from mssql-tools18. Measured
2026-09-05, go-sqlcmd 1.10.0 prints no Msg header on a severity 10 message at any -m value, so
there the PRINT lines arrive with no number.
Each failure was planted and watched going red, which is the only evidence an assertion asserts anything:
| Planted | What went red |
|---|---|
Dropped the AFTER INSERT block predicate |
3 |
Dropped the AFTER UPDATE block predicate |
4 |
WITH (STATE = OFF) |
1, 2, 4, 5 |
| Swapped in the predicate that returns rows when the tenant is unset | 5 |
Assertions 1 and 2 are the same claim from two angles: 2 catches a predicate that filters a direct scan and not an aggregate. Add the pooling case in application test code, because no single connection reproduces it: issue concurrent requests for two tenants against one pool and assert every response carries only its own tenant, the shape that measured 103 wrong answers in 200.
Do not
- Do not ship a filter predicate on its own. A cross-tenant insert succeeds under it and the row is then unreadable and undeletable by the application that wrote it.
- Do not add
AFTER INSERTand stop. The tenant-move update was measured succeeding with that block predicate in place. - Do not set the tenant in one pooled call and query in another, and do not reach for
@read_only = 1to make that safe. It refuses the correct caller and leaves the forgetful one leaking. - Do not treat
SESSION_CONTEXTas an identity. Any caller on the connection rewrites it with no permission, so one concatenated statement defeats the whole policy. - Do not assume the table owner or
db_ownerreads through the policy. They do not. They disable it, which is a different and visible act. - Do not grant
ALTER ANY SECURITY POLICYbelieving it is inert alone. Paired withALTERon the policy's schema it is a full read of every protected table, in one statement. - Do not read
is_enabled = 1as proof of isolation. A fail-open predicate and an uncovered table both leave it at 1. - Do not conclude row level security is unavailable because the container refuses it beside a vector index. The refusal is about the index, not about the policy.
- Do not leave the tenant column nullable. A row that arrives with no tenant is invisible to every tenant and belongs to none.
References
- Open the measured runs when a number above is disputed, before changing one, or to reproduce the pooling harness and the four planted failures.
- Read Row-level security before designing a predicate more complex than one column, for the block operations, the permission split, and the cross-feature list that names indexed views and temporal tables.
- Read sp_set_session_context
before choosing session context over a user per tenant: the 8,000 byte value limit, the 1 MB
session total, and the
@read_onlyargument. - Read sqlcmd utility when wiring
the isolation test into a pipeline, for what
-b,-mand-Vdo. prevent-sql-injectionwhen any statement here is assembled rather than parameterised, which is what a session context design depends on.entra-id-authfor getting an identity onto a working connection; use a custom least-privilege role for the permissions that identity receives.design-azure-sql-schemafor the tenant column and its place in the key,t-sql-correctnessfor the predicate function as T-SQL, andrag-on-azure-sqlwithvector-search-azure-sqlfor the retrieval this policy sits under.