Testland
Browse all skills & agents

rls-reference

Pure-reference catalog of row-level security for tenant isolation, Postgres-first. Covers enabling RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY, default-deny semantics), CREATE POLICY syntax (USING vs WITH CHECK clauses, FOR SELECT/INSERT/UPDATE/DELETE/ALL, permissive vs restrictive, TO role_name), bypassing RLS (superuser / BYPASSRLS / table owner / FORCE ROW LEVEL SECURITY), tenant context patterns (current_user, current_setting, JWT claims via Supabase auth.uid() / auth.jwt()), and performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns). Row/tenant isolation on the non-Postgres engines - MySQL / MariaDB invoker views, CockroachDB native RLS, Vitess vindex sharding, SQL Server security policies - lives in references/other-engines.md. Use as the RLS-pattern reference for tenant isolation on any of these engines. Consumed by cross-tenant-data-leak-tests.

Install with skills.sh (any agent)

npx skills add testland/qa --skill rls-reference
View source

rls-reference

Overview

Postgres Row-Level Security (RLS) lets the database itself enforce per-tenant row visibility, independent of the application code. It is the canonical defence-in-depth layer for pool / horizontally-partitioned tenancy models - even if the application forgets to add a WHERE tenant_id = ? filter, the database refuses to return another tenant's rows.

Per postgresql.org/docs/current/ddl-rowsecurity.html (opens in new window), RLS "restricts which rows users can access based on per-user policies." Unlike standard SQL GRANT / REVOKE privileges which act on whole tables, RLS controls row visibility and modification per role.

This skill is a pure reference consumed by the tenant-leak test authors and reviewers. Postgres is the primary engine; MySQL / MariaDB, CockroachDB, Vitess, and SQL Server are covered in references/other-engines.md. For the broader model context see the isolation-models reference in cross-tenant-data-leak-tests.

When to use

  • Designing tenant isolation on a Postgres-backed pool / bridge model (for MySQL / MariaDB, CockroachDB, Vitess, or SQL Server see references/other-engines.md).
  • Auditing existing RLS policies for correctness.
  • Writing tests that verify RLS denies cross-tenant access (per cross-tenant-data-leak-tests).
  • Onboarding a new table to the tenant_id discriminator pattern.

How to use

  1. Enable AND force RLS on every tenant-bearing table (ENABLE then FORCE ROW LEVEL SECURITY) so the table owner obeys policies too.
  2. Pick a tenant context source from references/tenant-context-patterns.md and confirm the app sets it per transaction (SET LOCAL) from an authenticated session, never from request input.
  3. Write the tenant policy using references/create-policy-syntax.md and a shape from references/policy-and-bypass-patterns.md - scope USING + WITH CHECK to the tenant discriminator.
  4. Confirm the app connection role is not a superuser, lacks BYPASSRLS, and either does not own the table or runs with FORCE ROW LEVEL SECURITY.
  5. Apply the performance discipline below: wrap auth functions in SELECT and index every policy-referenced column.
  6. Run the statement-level and application-level tests as a non-privileged role; assert cross-tenant reads and writes fail.
  7. Cross-check the policy against the Anti-patterns table before sign-off.

Enabling RLS

ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;

Per Postgres docs: "Once enabled, a default-deny policy applies - no rows are visible or modifiable unless explicitly allowed by a policy."

To disable:

ALTER TABLE accounts DISABLE ROW LEVEL SECURITY;

Force RLS on table owner

By default, the table owner bypasses RLS. For tenant isolation this is dangerous - the application's connecting role is often the table owner. Force the owner to obey policies too:

ALTER TABLE accounts FORCE ROW LEVEL SECURITY;

Per Postgres docs, this is the production-safe default for multi-tenant tables.

CREATE POLICY syntax

The full grammar plus USING vs WITH CHECK, per-command policies, permissive vs restrictive combination, and TO role_name scoping: references/create-policy-syntax.md.

Tenant context patterns

The policy needs a source of truth for the current tenant. Four canonical patterns - current_setting() with SET LOCAL, current_user / session_user, Supabase auth.uid() / auth.jwt(), and in-policy JWT claim parsing: references/tenant-context-patterns.md.

Policy patterns and bypass

The three tenant-isolation policy shapes (strict discriminator, tenant + per-row ACL, admin override), the three RLS bypass categories (superuser / BYPASSRLS / table owner), and the operations that always bypass RLS (FK, unique, TRUNCATE, REFERENCES): references/policy-and-bypass-patterns.md.

Performance discipline

Per Supabase RLS performance docs:

PracticeEffect
Wrap auth function calls in SELECTPostgres optimiser caches result per statement (initPlan); 94%+ improvement on large tables
Specify TO role_nameAvoids policy evaluation for unrelated roles
Index on policy-referenced columns99%+ improvement for tenant_id-filtered queries
Include explicit filter in queryHelps query planner even when RLS adds implicit filter

Bad:

USING ( auth.uid() = user_id )

Good (initPlan-cached):

USING ( (SELECT auth.uid()) = user_id )

Anti-patterns

Anti-patternWhy it failsFix
RLS enabled but no policyDefault-deny means no rows return - broken silentlyAlways create at least one policy after enabling
Policy on table without FORCE ROW LEVEL SECURITYTable owner bypasses; app role often is the ownerAdd FORCE ROW LEVEL SECURITY
current_user for tenant_id without per-tenant rolesTenants share a role; no isolationUse SET LOCAL or JWT claim
tenant_id from request header -> SET LOCALSpoofable; never derive from request inputDerive from authenticated session/JWT only
auth.uid() not wrapped in SELECTPer-row evaluation - major perf hit at scale(SELECT auth.uid()) for initPlan caching
No index on tenant_idSequential scan after policy filterbtree index on tenant_id always
Permissive policy + missing TO clauseApplies to all roles including superusersAlways specify TO app_user
Tests use superuser to verify policiesBypasses RLS - test passes but prod leaksTests must connect as a non-superuser without BYPASSRLS
Trusting raw_user_meta_data in Supabase policiesUser-modifiable per Supabase docsUse raw_app_meta_data (server-set)
Single policy mixing tenant + per-row ACL with ORPermissive OR widens access; one bad clause leaks tenantSplit into permissive (tenant) + restrictive (ACL)

Testing RLS policies

Two levels:

Statement-level

-- Connect as app_user (no BYPASSRLS, not table owner)
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM documents;  -- should match tenant 1's count only

SET LOCAL app.tenant_id = '22222222-2222-2222-2222-222222222222';
SELECT count(*) FROM documents;  -- should match tenant 2's count

-- Attempt cross-tenant
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO documents (tenant_id, body)
VALUES ('22222222-2222-2222-2222-222222222222', 'leak');
-- Must fail with: new row violates row-level security policy

Application-level

Run the existing test suite under a non-superuser, non-BYPASSRLS, non-table-owner role. If passing tests rely on RLS bypass, the suite is silently invalid for production. Per cross-tenant-data-leak-tests the gate test must use a tenant-A session to attempt access to tenant-B-owned rows and assert 0 rows returned.

Worked example

Onboard a documents table to the strict tenant_id discriminator (Pattern 1). Enable and force RLS, then create the policy scoped to app_user:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
    AS PERMISSIVE FOR ALL TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

The application runs SET LOCAL app.tenant_id = '<uuid>' from the authenticated session at the start of every transaction. Connected as app_user (no BYPASSRLS, not the table owner), the tester sets tenant 1 and runs SELECT count(*) FROM documents - it returns tenant 1's rows only. Setting tenant 1 and attempting INSERT INTO documents (tenant_id, body) VALUES ('<tenant-2-uuid>', 'leak') fails with new row violates row-level security policy, because WITH CHECK rejects the foreign tenant_id. The table is isolated for both reads and writes.

Limitations

  • Constraint side channels. FK and UNIQUE checks bypass RLS; this creates timing/error-message side channels.
  • RLS does not protect logs. A query that filters to 0 rows still appears in pg_stat_statements and slow-query logs with the same SQL text. Log sanitisation is a separate concern.
  • No RLS on views by default. A view that does SELECT * FROM tenants doesn't inherit RLS unless WITH (security_invoker = true) is set on the view (Postgres 15+).
  • Replication. Logical replication does not respect RLS by default; the subscriber sees all rows. Per-publication filters needed.
  • Schema migrations. ALTER TABLE for new columns runs as table owner - review whether new columns need to be added to policies.

References

CockroachDB - native RLS

View source (opens in new window)

CockroachDB - native RLS

CockroachDB supports native row-level security that closely mirrors Postgres RLS.

Enabling RLS

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Force table owners to obey policies too:
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

CREATE POLICY

CREATE POLICY policy_name ON table AS [PERMISSIVE | RESTRICTIVE] FOR [SELECT | INSERT | UPDATE | DELETE | ALL] TO role USING (condition) [WITH CHECK (condition)]. Per the CockroachDB RLS docs: USING filters rows on reads and updates; WITH CHECK validates writes and defaults to USING when omitted; permissive policies combine with OR, restrictive with AND; access is denied by default once RLS is enabled and no policy applies.

Tenant context via application_name

CockroachDB has no Postgres-style SET LOCAL + current_setting transaction variable. The canonical pattern reads the tenant from application_name, a session variable every client sets at connection open:

SET application_name = 'tenant:<uuid>';

CREATE POLICY tenant_isolation ON documents
    FOR ALL
    TO app_role
    USING (
        tenant_id = split_part(current_setting('application_name'), ':', 2)::uuid
    )
    WITH CHECK (
        tenant_id = split_part(current_setting('application_name'), ':', 2)::uuid
    );

Bypass risks

Per the CockroachDB RLS docs, these paths bypass RLS and need separate controls:

BypassBehaviour
Foreign key constraints and cascadesNot subject to RLS
Primary / unique key constraintsNot subject to RLS
TRUNCATENot subject to RLS
Change Data Capture (CDC)Queries fail with error when RLS is enabled
Backup and restoreIgnore RLS policies
Logical and physical cluster replicationIgnore RLS policies

Source: CockroachDB RLS cockroachlabs.com/docs/stable/row-level-security.html (opens in new window).

CREATE POLICY syntax reference

View source (opens in new window)

CREATE POLICY syntax reference

Full CREATE POLICY grammar plus the clause semantics a tenant policy relies on.

Grammar

CREATE POLICY policy_name ON table_name
    [ AS { PERMISSIVE | RESTRICTIVE } ]
    [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
    [ TO role_name [, ...] ]
    [ USING ( using_expression ) ]
    [ WITH CHECK ( check_expression ) ];

USING vs WITH CHECK

ClauseControls
USINGWhich rows are visible (SELECT, UPDATE, DELETE)
WITH CHECKWhich rows can be written (INSERT, UPDATE)

If only USING is specified, it implicitly applies to both.

Per Postgres docs, the canonical own-data UPDATE policy uses both:

CREATE POLICY user_policy ON users
    FOR UPDATE
    USING (user_name = current_user)
    WITH CHECK (
        user_name = current_user AND
        shell IN ('/bin/bash', '/bin/sh', '/bin/dash')
    );

USING ensures the user can only update their own row; WITH CHECK ensures they can't update the row into a state that violates other invariants.

Per-command policies

For SELECT/UPDATE divergence (a common tenant pattern: all users see all rows, but only modify their own):

CREATE POLICY user_sel_policy ON users
    FOR SELECT
    USING (true);

CREATE POLICY user_mod_policy ON users
    FOR UPDATE
    USING (user_name = current_user);

Permissive vs restrictive policies

TypeCombination
PERMISSIVE (default)Multiple policies combine with OR - any match grants access
RESTRICTIVEMultiple policies combine with AND - all must allow

Restrictive policies layer additional constraints on top of permissive ones. Per Postgres docs:

-- Permissive: anyone in role 'admin' or whose row matches
-- Restrictive: only allow when from local network
CREATE POLICY admin_local_only ON passwd
    AS RESTRICTIVE TO admin
    USING (pg_catalog.inet_client_addr() IS NULL);

TO role_name

If omitted, the policy applies to all users (TO PUBLIC). For tenant isolation, scope policies to the application role:

CREATE POLICY tenant_isolation ON documents
    TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

References

MySQL and MariaDB - view-based tenant isolation

View source (opens in new window)

MySQL and MariaDB - view-based tenant isolation

MySQL 8.x and MariaDB have no native row-level security statement. Isolation combines a view defined SQL SECURITY INVOKER with an application role that holds no direct grants on the base table.

Why INVOKER, not DEFINER

The CREATE VIEW default is SQL SECURITY DEFINER: the view runs with the creator's privileges, so any user with SELECT on the view reads every row the creator can read, including other tenants'. With SQL SECURITY INVOKER the view runs with the caller's privileges, so the view's WHERE clause becomes the tenant boundary. The only clauses material to isolation are SQL SECURITY { DEFINER | INVOKER } and WITH [CASCADED | LOCAL] CHECK OPTION; the MySQL 8.4 CREATE VIEW reference has the full grammar.

Canonical per-tenant view

CREATE VIEW tenant_docs AS
    SELECT *
    FROM documents
    WHERE tenant_id = /* app sets via session var or stored function */ ...
SQL SECURITY INVOKER
WITH CASCADED CHECK OPTION;

WITH CHECK OPTION rejects inserts or updates that would create rows outside the view's WHERE clause.

App-layer enforcement (required complement)

Views do not block TRUNCATE, constraint checks, or direct table access when the role holds table-level grants. The application must:

  1. Connect with a role that has NO SELECT/INSERT/UPDATE/DELETE on the base table (only on the view).
  2. Set the tenant identity before each query (session variable or a JWT-derived stored-function result).
  3. Treat the view WHERE clause as the sole row gate and re-validate it in every schema migration.

MariaDB shares this model. Its DEFINER clause also accepts role | CURRENT_ROLE, which makes a schema-per-tenant view owned by a per-tenant role viable at low tenant counts. Atomic DDL for CREATE VIEW landed in MariaDB 10.6.1.

Bypass risks

RiskWhy
SQL SECURITY DEFINER (default)View runs as creator; tenant filter is advisory only
Direct base-table grants on the app roleApp can bypass the view entirely
TRUNCATENever filtered by views; requires separate role restriction
Schema changes to the viewMigration that widens WHERE clause removes isolation

Sources: MySQL 8.4 CREATE VIEW dev.mysql.com/doc/refman/8.4/en/create-view.html (opens in new window); MariaDB CREATE VIEW mariadb.com/kb/en/create-view/ (opens in new window).

Row-level security on non-Postgres engines

View source (opens in new window)

Row-level security on non-Postgres engines

Companion reference for rls-reference. The host skill covers Postgres RLS in full; this file covers the four non-Postgres engines that appear most in multi-tenant B2B SaaS stacks: MySQL / MariaDB, CockroachDB, Vitess, and SQL Server.

Overview

The host skill covers Postgres RLS in full. This reference covers the four non-Postgres engines that appear most in multi-tenant B2B SaaS stacks. For each engine the spine below gives the isolation primitive, the tenant-context essence, and a minimal isolation test; the full context SQL, vindex / VSchema detail, and the per-engine bypass table live in the linked per-engine reference file. For the broader model-selection context see the isolation-models reference in cross-tenant-data-leak-tests.

When to use

  • Designing tenant isolation on a MySQL, MariaDB, CockroachDB, Vitess, or SQL Server backed service.
  • Auditing an existing isolation scheme for bypass risks.
  • Writing cross-tenant leak tests for any of these engines (companion to cross-tenant-data-leak-tests).
  • Translating a Postgres RLS design to one of these engines.

Engine cheat sheet

EngineIsolation primitiveTenant contextHighest-signal gotcha
MySQL / MariaDBSQL SECURITY INVOKER view + app-role grantsSession var / stored functionDefault DEFINER view leaks; app role must lack base-table grants
CockroachDBNative RLS (CREATE POLICY)application_name session varNo Postgres SET LOCAL current_setting; CDC/backup/replication bypass RLS
VitessKeyspace sharding + tenant_id vindextenant_id predicate on every queryQuery without tenant_id scatters to all shards; tenant_id must be immutable
SQL ServerCREATE SECURITY POLICY + inline TVF predicateSESSION_CONTEXT with @read_only = 1Pooled connection reuse leaks context without @read_only = 1

MySQL / MariaDB - views + app layer

No native RLS. A view defined SQL SECURITY INVOKER runs with the caller's privileges, so its WHERE clause is the tenant boundary; the default SQL SECURITY DEFINER runs as the creator and leaks other tenants' rows. WITH CASCADED CHECK OPTION blocks writes that fall outside the view. Isolation holds only if the app role has no direct grants on the base table. MariaDB shares this model, with role-owned per-tenant views as an option at low tenant counts.

-- Role has SELECT on the view only (not the base table)
SET @tenant_id = 'tenant-A';
SELECT COUNT(*) FROM tenant_docs;                             -- tenant-A rows only
SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-B';  -- must be denied
INSERT INTO tenant_docs (tenant_id, body) VALUES ('tenant-B', 'leak');  -- CHECK OPTION failed

Full view pattern, app-layer checklist, and bypass table: mysql-mariadb.md (opens in new window).

CockroachDB - native RLS

Native RLS mirroring Postgres. ALTER TABLE ... ENABLE ROW LEVEL SECURITY plus CREATE POLICY ... USING (...) WITH CHECK (...); access is denied by default once RLS is enabled and no policy applies. There is no Postgres SET LOCAL + current_setting transaction variable, so the tenant is read from the application_name session variable.

-- app_role: no BYPASSRLS, not table owner
SET application_name = 'tenant:11111111-1111-1111-1111-111111111111';
SELECT COUNT(*) FROM documents;                          -- tenant-1 rows only
INSERT INTO documents (tenant_id, body)
VALUES ('22222222-2222-2222-2222-222222222222', 'leak');  -- violates WITH CHECK
SET row_security = off;
SELECT * FROM documents;                                 -- errors if a policy would filter rows
RESET row_security;

Enable/force syntax, the application_name policy, and the bypass table (FK, TRUNCATE, CDC, backup, replication): cockroachdb.md (opens in new window).

Vitess - keyspace sharding + vindexes

No policy layer. A primary vindex on tenant_id routes each tenant's rows to a dedicated shard range; a query without a tenant_id predicate scatters to all shards and returns every tenant's rows, so the application must include tenant_id on every query. tenant_id is the sharding key and must be immutable - Vitess cannot move a row between shards on update.

-- Via VTGate: confirm the plan is a unique route, not a scatter
EXPLAIN SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-A';
-- plan_type must be "EqualUnique", not "Scatter"
UPDATE documents SET tenant_id = 'tenant-B' WHERE id = 1;  -- must be rejected at app layer

Vindex types, the VSchema fragment, and the bypass table: vitess.md (opens in new window).

SQL Server - security policy + predicate function

Native RLS since SQL Server 2016. CREATE SECURITY POLICY adds a FILTER predicate (silently filters SELECT/UPDATE/DELETE) and/or a BLOCK predicate (blocks violating writes); the predicate is an inline table-valued function created WITH SCHEMABINDING. For shared pools the tenant ID is carried in SESSION_CONTEXT, set with @read_only = 1 so a reused connection cannot carry a prior tenant's context.

EXECUTE AS USER = 'AppUser';
EXEC sp_set_session_context @key = N'TenantId', @value = 1, @read_only = 1;
SELECT COUNT(*) FROM dbo.Documents;                          -- tenant-1 rows only
INSERT INTO dbo.Documents (TenantId, Body) VALUES (2, 'leak');  -- BLOCK predicate fires
EXEC sp_set_session_context @key = N'TenantId', @value = 2;     -- fails: key is read-only
REVERT;

Predicate function + policy, the USER_NAME() alternative, and the bypass table (CDC, Change Tracking, indexed views, FILESTREAM): sqlserver.md (opens in new window).

Anti-patterns (all engines)

Anti-patternEngine(s)Why it failsFix
SQL SECURITY DEFINER view for isolationMySQL, MariaDBRuns as creator; any caller sees creator-scoped rows regardless of tenantUse SQL SECURITY INVOKER + restrict base-table grants
No WITH CHECK OPTION on isolation viewMySQL, MariaDBInserts into another tenant's row space silently succeedAdd WITH CASCADED CHECK OPTION
Direct base-table grants alongside view grantsMySQL, MariaDBApp can bypass the viewGrant only on the view, deny on the base table
Querying Vitess without a tenant_id predicateVitessScatters to all shards; returns all tenants' rowsEnforce tenant_id predicate in ORM / query layer
Mutable tenant_id (sharding key) in VitessVitessVitess cannot move rows between shards; the update silently corrupts or errorsDeclare tenant_id immutable at the application layer
SESSION_CONTEXT without @read_only = 1 in SQL ServerSQL ServerPooled connection reuse can carry a prior tenant's contextAlways set @read_only = 1 when writing to SESSION_CONTEXT
Tests run as db_owner / sysadmin in SQL ServerSQL ServerPolicy applies to these roles, but they can drop/alter the policy - test does not prove the policy is correct for app-roleTests must connect as the app role only
Policies applied to current temporal table but not history tableSQL ServerHistory table is unprotectedAdd a matching CREATE SECURITY POLICY on the history table separately

Limitations

  • MySQL / MariaDB: no database-enforced row gate equivalent to Postgres or SQL Server RLS; view isolation depends entirely on the app role lacking base-table privileges. A misconfigured ORM connection string that uses a privileged user bypasses all isolation.
  • CockroachDB: CDC, backup, and replication bypass RLS by design; these operational paths require separate data-governance controls.
  • Vitess: isolation is routing-based, not policy-based, and only as strong as the app's discipline in including tenant_id on every query. Vitess provides no mechanism to reject a scatter query at the database tier.
  • SQL Server: Change Data Capture leaks filtered rows to db_owner and the CDC gating role, and Change Tracking leaks primary keys, regardless of active security policies.

References

Tenant policy patterns and RLS bypass

View source (opens in new window)

Tenant policy patterns and RLS bypass

Canonical tenant-isolation policy shapes, plus the ways RLS can be bypassed that a tenant deployment must lock down.

Common policy patterns for tenant isolation

Pattern 1: Strict tenant_id discriminator

CREATE POLICY tenant_isolation ON documents
    AS PERMISSIVE
    FOR ALL
    TO app_user
    USING (tenant_id = current_setting('app.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

Both USING and WITH CHECK use the same expression - a tenant can't read OR insert rows for another tenant.

Pattern 2: Tenant + per-row ACL

CREATE POLICY tenant_isolation ON documents
    AS PERMISSIVE
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY owner_or_shared ON documents
    AS RESTRICTIVE
    USING (
        owner_id = current_setting('app.user_id')::uuid
        OR id IN (SELECT document_id FROM document_shares
                  WHERE shared_with = current_setting('app.user_id')::uuid)
    );

Permissive policy enforces tenant boundary; restrictive policy layers on per-row ACL.

Pattern 3: Admin override

CREATE POLICY tenant_isolation ON documents
    USING (
        tenant_id = current_setting('app.tenant_id')::uuid
        OR current_setting('app.is_global_admin', true)::boolean = true
    );

Global admin claim bypasses the tenant filter. Audit usage - admin connections must be logged and short-lived.

Bypassing RLS

Three categories of bypass per Postgres docs:

BypassWhen
SuperusersAlways (cannot be disabled)
Roles with BYPASSRLS attributeOperational tasks; create explicit roles, audit usage
Table ownersBypass by default unless FORCE ROW LEVEL SECURITY set

For production tenant tables, the application connection role must NOT be a superuser, NOT have BYPASSRLS, and must NOT own the table (or, if it owns the table, FORCE ROW LEVEL SECURITY must be set).

Detecting RLS-bypassed sessions

To prevent accidentally-bypassed query patterns from masquerading as policy-respecting:

SET row_security = off;
-- Subsequent queries error if a policy would have filtered

Useful in audit / backup contexts to catch unintended row exclusion.

Operations that always bypass RLS

Per Postgres docs, these operations are not subject to RLS:

  • Foreign key constraint checks
  • Unique constraint checks
  • TRUNCATE
  • REFERENCES privilege checks

This creates side-channel leaks: an attacker can probe for tenant-other rows via INSERT collisions on unique constraints, or via foreign-key reference patterns. Compensating controls:

  • Use UUID primary keys (not sequential ints) to avoid enumeration via FK
  • Treat unique-constraint timing differences as a side channel worth testing per cross-tenant-data-leak-tests

References

SQL Server - CREATE SECURITY POLICY with predicate functions

View source (opens in new window)

SQL Server - CREATE SECURITY POLICY with predicate functions

SQL Server has native RLS since SQL Server 2016 (13.x), also available on Azure SQL Database, Azure SQL Managed Instance, and Microsoft Fabric Warehouse.

Predicate types

PredicateEffect
FILTERSilently filters rows for SELECT, UPDATE, DELETE - the app sees an empty result, not an error
BLOCKExplicitly blocks writes (AFTER INSERT, AFTER UPDATE, BEFORE UPDATE, BEFORE DELETE) that violate the predicate

The predicate is an inline table-valued function created WITH SCHEMABINDING. SCHEMABINDING = ON (the default) means users querying the target table need no permission on the predicate function or its helper tables. The clauses material to isolation are ADD [FILTER | BLOCK] PREDICATE tvf(cols) ON table and WITH (STATE = ON); the CREATE SECURITY POLICY reference has the full grammar.

Tenant context via SESSION_CONTEXT

For shared connection pools, carry the tenant ID per connection with SESSION_CONTEXT():

CREATE FUNCTION Security.fn_tenant_predicate(@TenantId int)
    RETURNS TABLE
    WITH SCHEMABINDING
AS
    RETURN SELECT 1 AS result
    WHERE CAST(SESSION_CONTEXT(N'TenantId') AS int) = @TenantId;
GO

CREATE SECURITY POLICY Security.TenantFilter
    ADD FILTER PREDICATE Security.fn_tenant_predicate(TenantId) ON dbo.Documents,
    ADD BLOCK PREDICATE Security.fn_tenant_predicate(TenantId) ON dbo.Documents AFTER INSERT
    WITH (STATE = ON);
GO

EXEC sp_set_session_context @key = N'TenantId', @value = 42, @read_only = 1;

@read_only = 1 locks the SESSION_CONTEXT value until the connection returns to the pool - critical for pool reuse safety.

Alternative tenant context - USER_NAME()

For low-tenant-count deployments where each tenant maps to a SQL login:

CREATE FUNCTION Security.tvf_securitypredicate(@TenantRep AS nvarchar(50))
    RETURNS TABLE
    WITH SCHEMABINDING
AS
    RETURN SELECT 1 AS result
    WHERE @TenantRep = USER_NAME() OR USER_NAME() = 'GlobalAdmin';
GO

Bypass risks

RiskWhy
db_owner / sysadminPolicy applies but these roles can alter/drop it; audit all policy changes
DBCC SHOW_STATISTICSReports statistics on unfiltered data; restrict access to table owners
Change Data Capture (CDC)Leaks full rows to db_owner and the CDC gating role regardless of policy
Change TrackingLeaks primary keys of filtered rows to users with VIEW CHANGE TRACKING
Indexed viewsCannot be created on tables with a security policy
FILESTREAMIncompatible with RLS
Predicate relying on SET optionsSET DATEFORMAT/SET LANGUAGE can cause inconsistent filtering; use explicit CONVERT with a style parameter

Sources: SQL Server RLS learn.microsoft.com/en-us/sql/relational-databases/security/row-level-security (opens in new window); CREATE SECURITY POLICY learn.microsoft.com/en-us/sql/t-sql/statements/create-security-policy-transact-sql (opens in new window).

Tenant context patterns

View source (opens in new window)

Tenant context patterns

The policy needs a source of truth for the current tenant. Four canonical patterns.

1. current_setting() with SET LOCAL

Set the tenant at session / transaction start, read it in the policy:

-- Application code (every transaction):
SET LOCAL app.tenant_id = '<uuid>';

-- Policy:
CREATE POLICY tenant_isolation ON documents
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

SET LOCAL confines the value to the current transaction - critical when the connection is from a shared connection pool.

2. current_user / session_user

Map each tenant to a Postgres role. Suitable for low-tenant-count deployments (silo-leaning):

CREATE POLICY tenant_isolation ON documents
    USING (tenant_id = (SELECT id FROM tenants WHERE name = current_user));

3. Supabase auth.uid() / auth.jwt()

For Supabase-backed apps, JWT claims are exposed through helper functions. Per supabase.com/docs/guides/database/postgres/row-level-security (opens in new window):

CREATE POLICY "User can see their own profile only."
ON profiles FOR SELECT
USING ( (SELECT auth.uid()) = user_id );

Organisation / team membership via app_metadata:

CREATE POLICY "User is in team"
ON my_table TO authenticated
USING ( team_id IN (SELECT auth.jwt() -> 'app_metadata' -> 'teams') );

Critical: Per Supabase docs, never trust raw_user_meta_data for authorisation - it's user-modifiable. Use raw_app_meta_data (server-set) or a separate server-side claim store.

4. JWT claim parsing in policy

For self-rolled auth, parse the JWT or token directly:

CREATE POLICY tenant_isolation ON documents
    USING (tenant_id = (current_setting('request.jwt.claims', true)::jsonb->>'tenant_id')::uuid);

The true argument to current_setting makes it return NULL if the setting is absent (safer than erroring).

References

Vitess - keyspace sharding + vindexes

View source (opens in new window)

Vitess - keyspace sharding + vindexes

Vitess has no row-level security policy layer. Tenant isolation happens at the routing tier: a tenant's rows sit on a dedicated shard (or shard range), and vindexes route queries so cross-tenant access never reaches the wrong MySQL shard. A keyspace is a logical database; when sharded it maps to multiple MySQL databases across shards, and a vindex maps a column value to a keyspace ID.

Vindexes for tenant isolation

A primary vindex on tenant_id maps each tenant to a keyspace-ID range; Vitess routes every query carrying a tenant_id condition to the owning shard(s).

Vindex typeUse for tenant isolation
Functional hash (xxhash, hash)Maps tenant_id to a keyspace ID deterministically; no lookup table needed
Consistent lookup (unique)Stores tenant_id -> keyspace_id in a MySQL lookup table; supports non-hash distributions
Non-unique lookupSecondary routing on tenant-level subtables

Example VSchema fragment:

{
  "sharded": true,
  "vindexes": { "tenant_hash": { "type": "xxhash" } },
  "tables": {
    "documents": {
      "columnVindexes": [ { "column": "tenant_id", "name": "tenant_hash" } ]
    }
  }
}

Any query without a tenant_id condition scatters to all shards. Isolation therefore depends on every query carrying a tenant_id predicate - an application-layer responsibility.

Bypass risks

RiskWhy
Query without tenant_id predicateScatters to all shards; returns rows from every tenant
Cross-shard transactions2PC for cross-shard writes; isolation bugs can occur in rollback paths
Row updates that change tenant_idVitess cannot move rows between shards on update; tenant_id must be immutable
Direct MySQL access (bypassing VTGate)Shard-level MySQL has no vindex awareness; direct access bypasses routing

Sources: Vitess Vindexes vitess.io/docs/21.0/reference/features/vindexes/ (opens in new window); Vitess Keyspaces vitess.io/docs/21.0/concepts/keyspace/ (opens in new window).

Related skills

cross-tenant-data-leak-tests

Workflow-driven skill that plans and implements the cross-tenant leak-test suite - from surface inventory to the runtime CI gate a multi-tenant codebase must pass on every PR. The planning section inventories tenant-bearing surfaces (tables, APIs, object storage, search, queues, caches), classifies each by isolation model (silo / pool / bridge, per references/isolation-models.md), and derives the OWASP WSTG-ATHZ-02 coverage matrix. The battery defines the canonical test patterns (read-other-tenant-by-id, list-leak, spoofed-tenant-id-in-body, JWT-replay, FK-cross-tenant, unique-collision side channel, object-storage IDOR, search-index-direct-query, async-job-context-reload, cache-key-collision), the 404-vs-403 disclosure trade-off, the Postgres-RLS-direct patterns, and the CI integration (non-superuser non-BYPASSRLS role, fail the build on any leak). Use when designing or implementing a tenant-isolation test suite, adding the CI gate to an existing project, or investigating a leak finding.

tenant-onboarding-test-author

Workflow-driven skill that authors a test suite for tenant provisioning and offboarding: account creation, isolation at creation (no cross-tenant bleed from a new tenant's first API call), default resource quotas, billing record linkage, seed and default data correctness, idempotent re-provisioning, and teardown with full data deletion. Walks through mapping provisioning surfaces, generating test cases per surface, emitting the test suite skeleton (pytest / Jest / JUnit / Go test), and producing a coverage matrix. Use when a new tenant onboarding flow is introduced or changed, when the offboarding pipeline is modified, or when auditing provisioning coverage before a compliance review. Distinct from cross-tenant-data-leak-tests (leak-test planning + runtime CI gate): this skill covers the provisioning lifecycle, not steady-state access control.