Overview
Plan first, then implement. The planning section below produces the surface × pattern coverage matrix; the numbered steps produce the executing tests - the actual code that fails the build when isolation breaks.
The contract:
When to use
Planning the leak-test suite
Every product has a different surface area, so the suite is built from an inventory, not a pre-canned set of tests.
Inventory tenant-bearing surfaces
Walk the codebase and enumerate every surface that should be tenant-scoped:
| Surface category | Examples | How to find |
|---|---|---|
| Database tables | tables with tenant_id column | grep -r "tenant_id" --include="*.sql" ; ORM model field annotations |
| API endpoints | routes returning tenant data | route registrations grep |
| Object storage | buckets / prefixes per tenant | IaC for buckets, lifecycle config |
| Search indices | tenant-routed Elasticsearch / Algolia | index-naming scheme |
| Async messages | tenant_id in message attributes / payload | message-class definitions |
| Caches | Redis keys with tenant_id prefix | cache-client wrappers |
| Logs / metrics | log lines containing tenant_id | log-emit grep |
| Background jobs | Sidekiq / Celery tasks taking tenant_id | task definitions |
| Reports / exports | tenant-scoped reports | export endpoints |
| Webhooks / outbound | tenant-routed external calls | webhook configuration |
Classify each surface by isolation model - silo / pool / bridge / vertically-partitioned, per references/isolation-models.md. The test surface depends on the lowest isolation level in the stack: pool surfaces get the canonical battery below, bridge surfaces get cross-database-routing tests, silo surfaces get tenant-to-deployment routing tests (and the shared management surface still gets the pool battery).
Enumerate attack patterns per surface
Per OWASP WSTG-ATHZ-02 (owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema (opens in new window)), three primary scenarios: horizontal escalation (tenant A accesses tenant B's data at identical privilege), vertical escalation (non-admin reaches admin-only resources), and IDOR / BOLA (direct-reference attack on any ID-bearing endpoint). Layer the tenant-specific patterns (spoofed-body tenant_id, cross-tenant FK / unique-constraint, JWT replay, storage path traversal, unfiltered search, async-job context, cache-key collision) on top - the full test-per-pattern catalog is in references/attack-patterns.md.
Generate test cases and fixtures
One or more test cases per (surface, pattern) cell, named test_<surface>_<pattern>_<expected>(). Each test fixtures two disjoint tenants with users of identical privilege (tenant_a, tenant_b, tenant_a_user, tenant_b_user, tenant_a_admin, tenant_a_resource, tenant_b_resource), performs the cross-access attempt, and asserts denial. Pick the runner by stack (pytest / Jest+Supertest / JUnit 5+Testcontainers / Go httptest / RSpec); ready-made skeletons are in references/framework-skeletons.md.
Track coverage
| horiz | vert | IDOR | jwt | fk | cache | log
documents | X | X | X | X | - | X | -
attachments | X | - | X | X | - | - | -
search_index | X | - | X | X | - | - | -
audit_log | X | - | - | - | - | - | XGenerate the matrix from the surface inventory × the pattern list. Empty cells are coverage gaps the PR must justify. The planning output - surface inventory, coverage matrix, suite skeleton, and a test-directory README - is committed to the project repo; the steps below implement the battery.
Step 1 - Choose the test runner and DB connection role
For Postgres-backed apps, this is the most-common-bug step:
| Connection used in test | Result |
|---|---|
| Superuser | Bypasses RLS - tests pass, prod leaks. Do not use. |
Role with BYPASSRLS attribute | Same as superuser. Do not use. |
Role that owns the tenant table (and table not FORCEd) | Bypasses RLS. Do not use unless FORCE ROW LEVEL SECURITY is set. |
| Plain application role (no BYPASSRLS, not owner) | Correct - same role prod uses. |
Per references/row-level-security.md, verify with:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles
WHERE rolname = current_user;
-- Expect: rolsuper=f, rolbypassrls=fFor Django, set DATABASES['default']['USER'] to the app role in the test settings. For Rails, config/database.yml test section. For Spring Boot, spring.datasource.username in application-test.yml.
Step 2 - The canonical battery
These tests should exist for every tenant-bearing API surface.
Test 1 - Read-other-tenant-by-id
def test_get_other_tenant_resource_returns_404(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
resp = client.get(f"/api/documents/{tenant_b_resource.id}/")
assert resp.status_code == 404 # not 403 - avoid existence disclosureConvention: return 404, not 403, for resources the requester can't access in another tenant. 403 leaks existence (tenant A learns tenant B has resource with this ID). The trade-off: debugging slightly harder. Document the project's choice.
Test 2 - List-leak
def test_list_does_not_include_other_tenant_resources(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
resp = client.get("/api/documents/")
assert resp.status_code == 200
ids = {d["id"] for d in resp.json()["results"]}
assert tenant_b_resource.id not in idsEven with RLS enforcing visibility, application-layer caches can leak. Test against fresh queries.
Test 3 - Spoofed-tenant-id-in-body
def test_tenant_id_in_body_ignored_or_rejected(
self, client, tenant_a_user, tenant_b
):
client.force_login(tenant_a_user)
resp = client.post("/api/documents/", json={
"tenant_id": str(tenant_b.id),
"body": "leak"
})
if resp.status_code == 201:
created = resp.json()
assert created["tenant_id"] != str(tenant_b.id), \
"Server accepted tenant_id from body - must derive from session"Test 4 - JWT replay across tenants
def test_jwt_for_tenant_a_rejected_on_tenant_b_path(
self, client, tenant_a_user, tenant_b_resource
):
token = sign_jwt(tenant_a_user)
resp = client.get(
f"/api/documents/{tenant_b_resource.id}/",
HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert resp.status_code in (401, 404)If the API has tenant-scoped paths (/api/tenants/<id>/...), also test that tenant A's JWT cannot be used with tenant B's path even with a valid signature - the tenant_id claim must be checked against the path.
Test 5 - FK cross-tenant
def test_cannot_create_fk_referencing_other_tenant(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
# tenant_b_resource exists, but A shouldn't reference it
resp = client.post("/api/comments/", json={
"document_id": str(tenant_b_resource.id),
"body": "comment on other tenant's doc"
})
assert resp.status_code in (400, 404)This tests FK-based leak via reference: the FK constraint bypasses RLS per references/row-level-security.md, so the FK must be validated at application layer too.
Test 6 - Unique-collision side channel
def test_unique_violation_does_not_disclose_other_tenant_existence(
self, client, tenant_a_user, tenant_b_resource_with_slug
):
client.force_login(tenant_a_user)
# Try to create with same slug as tenant B
resp = client.post("/api/documents/", json={
"slug": tenant_b_resource_with_slug.slug,
"body": "x"
})
# Should succeed (RLS scopes the unique check to tenant A)
# or fail with a non-disclosing error if unique is global
if resp.status_code in (409, 422):
assert "tenant_b" not in resp.text.lower()
assert tenant_b_resource_with_slug.id not in resp.textPer references/row-level-security.md: "Foreign key constraint checks, Unique constraint checks, TRUNCATE, and REFERENCES privilege checks bypass RLS." Solution: make slug unique per tenant: UNIQUE (tenant_id, slug).
Test 7 - Object-storage IDOR
def test_storage_presigned_url_path_traversal_denied(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
presigned = client.get(
f"/api/documents/{tenant_a_user.tenant_id}/file/"
).json()["url"]
# Modify the URL to point at tenant B's prefix
leaked_url = presigned.replace(
str(tenant_a_user.tenant_id),
str(tenant_b_resource.tenant_id)
)
resp = requests.get(leaked_url)
assert resp.status_code == 403The S3 / GCS bucket policy must enforce the prefix - application code is not sufficient.
Test 8 - Search-index direct query
def test_search_query_must_include_tenant_filter(
self, opensearch_client, tenant_a, tenant_b_resource
):
# Direct ES query without tenant filter (simulating leaked path)
result = opensearch_client.search(
index="documents",
body={"query": {"match_all": {}}}
)
# Test asserts the API endpoint always adds a tenant filter.
# The DIRECT search above should not be reachable from any API path.
# This test ensures no route exists that issues unfiltered search.
for route in app.url_map.iter_rules():
if "search" in route.endpoint:
assert "@tenant_required" in inspect.getsource(
app.view_functions[route.endpoint]
)Test 9 - Async-job context-reload
def test_async_job_reloads_tenant_from_db_not_payload(
self, tenant_a, tenant_b_resource
):
# Enqueue a job with a payload pointing at tenant B's resource
job = enqueue_export(
resource_id=tenant_b_resource.id,
# Crafted to spoof - but executor must verify
tenant_id_claim=tenant_a.id
)
result = run_job(job)
# Executor must derive tenant_id from resource_id, not the payload
assert result.tenant_id == tenant_b_resource.tenant_idTest 10 - Cache key collision
def test_cache_keys_are_tenant_scoped(self, cache, tenant_a, tenant_b):
cache.set("user:1", "tenant_a_data", tenant_id=tenant_a.id)
cache.set("user:1", "tenant_b_data", tenant_id=tenant_b.id)
assert cache.get("user:1", tenant_id=tenant_a.id) == "tenant_a_data"
assert cache.get("user:1", tenant_id=tenant_b.id) == "tenant_b_data"Cache wrappers must prepend tenant_id to every key.
Postgres-RLS-direct (test 1 - 6 at DB layer)
Run these as the application role (not superuser):
BEGIN;
SET LOCAL ROLE app_user;
SET LOCAL app.tenant_id = '<tenant_a_uuid>';
INSERT INTO documents (id, tenant_id, body)
VALUES (gen_random_uuid(), current_setting('app.tenant_id')::uuid, 'a');
SET LOCAL app.tenant_id = '<tenant_b_uuid>';
SELECT count(*) FROM documents; -- expect 0
-- Cross-tenant INSERT
INSERT INTO documents (tenant_id, body) VALUES ('<tenant_a_uuid>', 'leak');
-- Expect: ERROR: new row violates row-level security policy for table "documents"
ROLLBACK;Per references/row-level-security.md: test fails if either assertion fails (count != 0, or INSERT succeeds).
Step 3 - CI integration
# .github/workflows/tenant-isolation.yml
name: tenant-isolation
on:
pull_request:
paths:
- "**/*.py"
- "**/migrations/**"
- ".github/workflows/tenant-isolation.yml"
jobs:
cross-tenant-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ${{ secrets.CI_DB_PASSWORD }}
options: >-
--health-cmd pg_isready
--health-interval 10s
ports:
- 5432:5432
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install
run: pip install -e ".[test]"
- name: Create non-superuser role
env:
PGPASSWORD: ${{ secrets.CI_DB_PASSWORD }}
APP_DB_PASSWORD: ${{ secrets.CI_APP_DB_PASSWORD }}
run: |
psql -h localhost -U postgres -d postgres -c "
CREATE ROLE app_user LOGIN PASSWORD '$APP_DB_PASSWORD';
"
- name: Apply migrations
env:
DATABASE_URL: postgresql://postgres:${{ secrets.CI_DB_PASSWORD }}@localhost/test
run: python manage.py migrate
- name: Run cross-tenant suite
env:
DATABASE_URL: postgresql://app_user:${{ secrets.CI_APP_DB_PASSWORD }}@localhost/test
run: pytest tests/tenant_isolation/ --tb=short --no-header -vBoth DB passwords come from repository secrets, which per the GitHub Actions contexts reference (opens in new window) are readable in service-container env and step env. Never write a literal DB password into the workflow, even for a throwaway CI database.
Key: the test job connects as app_user, not as the postgres superuser. The migrations run as superuser; the tests run as the application role.
Step 4 - Diagnose a failure
When a leak test fails:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Tests connect as superuser | RLS bypassed; tests pass falsely | Use prod-equivalent role |
| Migrations and tests use same role | Migrations need DDL; tests should not | Two roles: migrations user (owner), app user (RLS-bound) |
| Test setup creates tenants via direct SQL bypassing RLS | OK in setup, but reset tenant context before assertions | SET LOCAL between fixture creation and test body |
| 403 used instead of 404 for unauthorised cross-tenant resources | Leaks existence | Use 404 (and document) |
| Single test for "tenant isolation works" | Insufficient coverage | One test per (surface, pattern) cell |
| Skipping FK/UNIQUE side-channel tests | Real production bugs hide here | Always test FK + unique cross-tenant |
| No async-job context-reload test | Job runners often trust payload | Always test |
| Cache without tenant prefix | Tenant A and B alias same logical key | Verify key generation in test |
| Suite not part of CI | Catches nothing | Block merge on failure |
Limitations
References
OWASP WSTG-ATHZ-02 scenarios
Three primary authorization-bypass scenarios apply to every pool/bridge surface:
| Pattern | What | Surface |
|---|---|---|
| Horizontal escalation | Tenant A accesses tenant B's data at identical privilege | All pool/bridge surfaces |
| Vertical escalation | Non-admin in tenant A accesses admin-only resources | All admin-scoped surfaces |
| IDOR / BOLA | Direct reference attack - change an ID in URL/payload | All ID-bearing endpoints |
Tenant-isolation-specific patterns
| Pattern | Test |
|---|---|
| tenant_id from request payload | Send tenant A's session with tenant_id=B in body - must reject |
| Missing tenant_id filter in new endpoint | Enumerate routes added in last N commits; verify each filters by tenant |
| Cross-tenant via foreign key | Create FK from tenant-A row to tenant-B row - must fail |
| Cross-tenant via unique constraint | Insert tenant-A row with a key that exists in tenant B - observe error timing as a side channel |
| JWT replay across tenants | Tenant A's JWT used to call tenant B's endpoint - must reject on signature/iss/aud check |
| Object storage path traversal | Tenant A presigned URL -> modify prefix to tenant B's - must 403 |
| Search query without tenant filter | Direct search index query - must include the tenant routing key |
| Async job tenant context | Job enqueued by tenant A -> executor must reload tenant context, not trust the message |
| Cache key collision | Tenant A and tenant B have the same logical key - cache must namespace |
| Log scrubbing | Tenant A errors must not leak tenant B identifiers |
Source: OWASP WSTG-ATHZ-02 Testing for Bypassing Authorization Schema owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema (opens in new window).
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:
| Bypass | Behaviour |
|---|---|
| Foreign key constraints and cascades | Not subject to RLS |
| Primary / unique key constraints | Not subject to RLS |
TRUNCATE | Not subject to RLS |
| Change Data Capture (CDC) | Queries fail with error when RLS is enabled |
| Backup and restore | Ignore RLS policies |
| Logical and physical cluster replication | Ignore RLS policies |
Source: CockroachDB RLS cockroachlabs.com/docs/stable/row-level-security.html (opens in new window).
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
| Clause | Controls |
|---|---|
USING | Which rows are visible (SELECT, UPDATE, DELETE) |
WITH CHECK | Which 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
| Type | Combination |
|---|---|
| PERMISSIVE (default) | Multiple policies combine with OR - any match grants access |
| RESTRICTIVE | Multiple 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
pytest - full horizontal-escalation battery
import pytest
class TestDocumentsTenantIsolation:
"""Per OWASP WSTG-ATHZ-02 - horizontal escalation battery."""
def test_tenant_a_cannot_read_tenant_b_document(
self, client, tenant_a_user, tenant_b_resource
):
# Authenticate as tenant A user
client.force_login(tenant_a_user)
# Attempt to access tenant B's resource by ID
response = client.get(f"/api/documents/{tenant_b_resource.id}/")
assert response.status_code == 404, "Must return 404, not 403, to avoid existence disclosure"
def test_tenant_a_cannot_list_tenant_b_documents(
self, client, tenant_a_user, tenant_b_resource
):
client.force_login(tenant_a_user)
response = client.get("/api/documents/")
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert tenant_b_resource.id not in ids
def test_tenant_id_in_body_is_ignored(
self, client, tenant_a_user, tenant_b
):
client.force_login(tenant_a_user)
# Attempt to create a document for tenant B by spoofing the body
response = client.post(
"/api/documents/",
data={"tenant_id": str(tenant_b.id), "body": "leak"}
)
# Must be rejected (400) or silently scoped to A (201 with A's tenant_id)
if response.status_code == 201:
doc = response.json()
assert doc["tenant_id"] != str(tenant_b.id)
def test_jwt_signed_for_a_rejected_on_b_endpoint(
self, client, tenant_a_user, tenant_b_resource
):
# Sign a JWT for tenant A user, use it on a B-scoped endpoint
token = sign_jwt_for(tenant_a_user)
response = client.get(
f"/api/documents/{tenant_b_resource.id}/",
HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code in (401, 404)Postgres RLS-direct (language-agnostic)
For surfaces relying on RLS per row-level-security.md (opens in new window), also test at the DB layer:
-- Connect as app_user (not superuser, not table owner)
BEGIN;
SET LOCAL app.tenant_id = '<tenant_a_uuid>';
-- Insert a row for tenant A
INSERT INTO documents (tenant_id, body) VALUES (current_setting('app.tenant_id')::uuid, 'a-doc');
-- Switch to tenant B
SET LOCAL app.tenant_id = '<tenant_b_uuid>';
SELECT count(*) FROM documents; -- expect 0 (tenant A's row invisible)
-- Cross-tenant INSERT attempt
INSERT INTO documents (tenant_id, body) VALUES ('<tenant_a_uuid>', 'leak');
-- Expect: ERROR: new row violates row-level security policy for table "documents"
ROLLBACK;Companion reference for cross-tenant-data-leak-tests. The isolation model in use (silo / pool / bridge / vertically-partitioned) decides which test surfaces the planning section must cover.
Overview
Tenant isolation is the foundational concern of every B2B SaaS architecture: the AWS Well-Architected SaaS Lens calls it essential and treats crossing a tenant boundary as a significant, potentially unrecoverable event for a SaaS business. Isolation is a continuum, not a binary - Microsoft's Azure Architecture Center frames it as a spectrum from shared-nothing to everything-shared, with architectures often picking different points per tier (UI shared, app shared, data isolated). This is a pure reference consumed by the leak-test planning section and the tenant-leak critic; it executes nothing.
When to use
Tenant vs deployment
A tenant is a logical customer boundary; a deployment (also called a stamp or supertenant) is a physical set of infrastructure. One deployment can host many tenants (shared model), or each tenant can have its own deployment (silo). The tenant-to-deployment mapping is durable state: a routing table must exist somewhere so requests reach the right deployment.
The four canonical models
| Model | Compute | Data | Network | Cost/tenant | Blast radius | Noisy neighbor |
|---|---|---|---|---|---|---|
| Automated single-tenant (silo) | Dedicated | Dedicated | Dedicated | Highest | One tenant | None |
| Fully multitenant (pool) | Shared | Shared (tenant_id discriminator) | Shared | Lowest | All tenants | High |
| Horizontally partitioned (bridge) | Shared | Dedicated per tenant | Shared | Medium | Data isolated | Data tier: none |
| Vertically partitioned | Mix | Mix | Mix | Mixed | Per-tier | Per-tier |
Per-model when-to-choose guidance, the sourced Microsoft framing, and each model's test surface are in models.md (opens in new window).
Isolation enforcement primitives
Tenant isolation is implemented by combining:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
tenant_id filter only in application code | One missed query path = cross-tenant leak | Push the filter to the database (RLS) or row-attribute IAM |
tenant_id from request header / body | Spoofable; tenant A can claim to be tenant B | Always derive tenant_id from authenticated JWT/session, never from request payload |
Trust the JWT raw_user_meta_data for tenant claims | User-modifiable per Supabase docs | Use raw_app_meta_data (server-set) or a server-side claim store |
| Single connection pool for all tenants | One slow tenant query blocks all | Per-tenant pools, or quota-aware pools |
| Shared object-storage bucket without prefix isolation | Object enumeration leaks across tenants | Per-tenant prefix + IAM condition on the prefix |
| No isolation tests in CI | Models drift over time | Cross-tenant leak tests in every PR per cross-tenant-data-leak-tests |
| Migration scripts run without tenant context | Schema changes touch all tenants at once; high blast radius | Stamp pattern with progressive rollout |
Test surface
The required test categories per model, plus the per-tier isolation mapping, are in test-surfaces.md (opens in new window). The cross-tenant data leak suite is the universal floor: even silo deployments share some surface (account-management APIs, billing, identity providers) where pool-like leaks are possible.
Limitations
References
The four canonical tenant-isolation models
View source (opens in a new tab) (opens in new window)Naming across frameworks: Microsoft's automated-single-tenant / fully-multitenant / horizontally-partitioned / vertically-partitioned; AWS Well-Architected's silo / pool / bridge. Deployments are also called stamps or supertenants.
1. Automated single-tenant (silo / fully-isolated)
| Property | Value |
|---|---|
| Compute | Dedicated per tenant |
| Data | Dedicated per tenant |
| Network | Dedicated per tenant |
| Cost per tenant | Highest |
| Blast radius | One tenant |
| Noisy neighbor | None |
Microsoft's framing: deploying a dedicated set of infrastructure per tenant isolates each tenant's data and reduces the risk of accidental leakage.
When to choose: regulated industries with strong isolation mandates (healthcare HIPAA, financial services, government); a small number of high-value enterprise customers; per-tenant configuration is part of the value proposition.
Test surface: deployment automation (the Deployment Stamps pattern); cross-deployment operations like reporting; tenant-to-deployment routing.
2. Fully multitenant (pool / fully-shared)
| Property | Value |
|---|---|
| Compute | Shared |
| Data | Shared (single DB with tenant_id discriminator) |
| Network | Shared |
| Cost per tenant | Lowest |
| Blast radius | All tenants |
| Noisy neighbor | High |
Microsoft's risk framing: separate each tenant's data and don't leak across tenants; a large tenant running a heavy query or operation might affect other tenants.
When to choose: a large number of low-margin customers; high operational efficiency required; tenants accept shared infrastructure.
Test surface: cross-tenant data leak (the canonical risk), tenant_id propagation through every code path, noisy-neighbor behaviour, resource quotas per tenant.
3. Horizontally partitioned (bridge)
| Property | Value |
|---|---|
| Compute | Shared |
| Data | Dedicated per tenant |
| Network | Shared |
| Cost per tenant | Medium |
| Blast radius | App-tier shared, data isolated |
| Noisy neighbor | App-tier yes, data tier no |
Microsoft's framing: a single application tier with an individual database per tenant, which mitigates the noisy-neighbor problem in the data tier.
When to choose: data isolation matters for compliance, but shared compute is acceptable; data-tier noisy neighbors are the dominant failure mode (heavy queries, large indexes).
Test surface: correct database routing per tenant; connection-pool exhaustion under tenant concurrency; cross-DB query attempts must fail.
4. Vertically partitioned
| Property | Value |
|---|---|
| Compute | Mix (some tenants dedicated, others shared) |
| Data | Mix |
| Network | Mix |
| Cost per tenant | Mixed |
| Blast radius | Per-tier decision |
| Noisy neighbor | Per-tier |
Microsoft's framing: a combination of single-tenant and multitenant deployments - most customers' data and application tiers on multitenant infrastructure, with single-tenant infrastructure for customers who require higher performance or data isolation. Includes geographic partitioning (one deployment per region, tenants mapped to the nearest region).
When to choose: the majority of customers fit the shared model, but a minority need silo (enterprise tier); geographic data-residency requirements.
Test surface: every test from the shared model plus every test from the silo model; tenant migration between tiers; pricing tier enforcement.
Source: Microsoft Azure Architecture Center - Tenancy Models learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/tenancy-models (opens in new window).
MySQL and MariaDB - view-based tenant isolation
View source (opens in a new tab) (opens in new window)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:
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
| Risk | Why |
|---|---|
SQL SECURITY DEFINER (default) | View runs as creator; tenant filter is advisory only |
| Direct base-table grants on the app role | App can bypass the view entirely |
TRUNCATE | Never filtered by views; requires separate role restriction |
| Schema changes to the view | Migration 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 a new tab) (opens in new window)Companion reference for row-level-security.md (opens in new window). That file 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
That file 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
Engine cheat sheet
| Engine | Isolation primitive | Tenant context | Highest-signal gotcha |
|---|---|---|---|
| MySQL / MariaDB | SQL SECURITY INVOKER view + app-role grants | Session var / stored function | Default DEFINER view leaks; app role must lack base-table grants |
| CockroachDB | Native RLS (CREATE POLICY) | application_name session var | No Postgres SET LOCAL current_setting; CDC/backup/replication bypass RLS |
| Vitess | Keyspace sharding + tenant_id vindex | tenant_id predicate on every query | Query without tenant_id scatters to all shards; tenant_id must be immutable |
| SQL Server | CREATE SECURITY POLICY + inline TVF predicate | SESSION_CONTEXT with @read_only = 1 | Pooled 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 failedFull 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 layerVindex 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-pattern | Engine(s) | Why it fails | Fix |
|---|---|---|---|
SQL SECURITY DEFINER view for isolation | MySQL, MariaDB | Runs as creator; any caller sees creator-scoped rows regardless of tenant | Use SQL SECURITY INVOKER + restrict base-table grants |
No WITH CHECK OPTION on isolation view | MySQL, MariaDB | Inserts into another tenant's row space silently succeed | Add WITH CASCADED CHECK OPTION |
| Direct base-table grants alongside view grants | MySQL, MariaDB | App can bypass the view | Grant only on the view, deny on the base table |
Querying Vitess without a tenant_id predicate | Vitess | Scatters to all shards; returns all tenants' rows | Enforce tenant_id predicate in ORM / query layer |
Mutable tenant_id (sharding key) in Vitess | Vitess | Vitess cannot move rows between shards; the update silently corrupts or errors | Declare tenant_id immutable at the application layer |
SESSION_CONTEXT without @read_only = 1 in SQL Server | SQL Server | Pooled connection reuse can carry a prior tenant's context | Always set @read_only = 1 when writing to SESSION_CONTEXT |
Tests run as db_owner / sysadmin in SQL Server | SQL Server | Policy applies to these roles, but they can drop/alter the policy - test does not prove the policy is correct for app-role | Tests must connect as the app role only |
| Policies applied to current temporal table but not history table | SQL Server | History table is unprotected | Add a matching CREATE SECURITY POLICY on the history table separately |
Limitations
References
Tenant policy patterns and RLS bypass
View source (opens in a new tab) (opens in new window)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:
| Bypass | When |
|---|---|
| Superusers | Always (cannot be disabled) |
Roles with BYPASSRLS attribute | Operational tasks; create explicit roles, audit usage |
| Table owners | Bypass 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 filteredUseful in audit / backup contexts to catch unintended row exclusion.
Operations that always bypass RLS
Per Postgres docs, these operations are not subject to RLS:
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:
References
Row-level security for tenant isolation
View source (opens in a new tab) (opens in new window)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 reference is used by tenant-leak test authors and reviewers. Postgres is the primary engine; MySQL / MariaDB, CockroachDB, Vitess, and SQL Server are covered in other-engines.md (opens in new window). For the broader model context see isolation-models.md (opens in new window).
When to read this
How to use
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: create-policy-syntax.md (opens in new window).
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: tenant-context-patterns.md (opens in new window).
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): policy-and-bypass-patterns.md (opens in new window).
Performance discipline
Per Supabase RLS performance docs:
| Practice | Effect |
|---|---|
Wrap auth function calls in SELECT | Postgres optimiser caches result per statement (initPlan); 94%+ improvement on large tables |
Specify TO role_name | Avoids policy evaluation for unrelated roles |
| Index on policy-referenced columns | 99%+ improvement for tenant_id-filtered queries |
| Include explicit filter in query | Helps 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-pattern | Why it fails | Fix |
|---|---|---|
| RLS enabled but no policy | Default-deny means no rows return - broken silently | Always create at least one policy after enabling |
Policy on table without FORCE ROW LEVEL SECURITY | Table owner bypasses; app role often is the owner | Add FORCE ROW LEVEL SECURITY |
current_user for tenant_id without per-tenant roles | Tenants share a role; no isolation | Use SET LOCAL or JWT claim |
tenant_id from request header -> SET LOCAL | Spoofable; never derive from request input | Derive from authenticated session/JWT only |
auth.uid() not wrapped in SELECT | Per-row evaluation - major perf hit at scale | (SELECT auth.uid()) for initPlan caching |
| No index on tenant_id | Sequential scan after policy filter | btree index on tenant_id always |
| Permissive policy + missing TO clause | Applies to all roles including superusers | Always specify TO app_user |
| Tests use superuser to verify policies | Bypasses RLS - test passes but prod leaks | Tests must connect as a non-superuser without BYPASSRLS |
| Trusting raw_user_meta_data in Supabase policies | User-modifiable per Supabase docs | Use raw_app_meta_data (server-set) |
| Single policy mixing tenant + per-row ACL with OR | Permissive OR widens access; one bad clause leaks tenant | Split 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 policyApplication-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 the main skill, 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
References
SQL Server - CREATE SECURITY POLICY with predicate functions
View source (opens in a new tab) (opens in new window)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
| Predicate | Effect |
|---|---|
FILTER | Silently filters rows for SELECT, UPDATE, DELETE - the app sees an empty result, not an error |
BLOCK | Explicitly 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';
GOBypass risks
| Risk | Why |
|---|---|
db_owner / sysadmin | Policy applies but these roles can alter/drop it; audit all policy changes |
DBCC SHOW_STATISTICS | Reports 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 Tracking | Leaks primary keys of filtered rows to users with VIEW CHANGE TRACKING |
| Indexed views | Cannot be created on tables with a security policy |
FILESTREAM | Incompatible with RLS |
Predicate relying on SET options | SET 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).
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
Test surface by tenant-isolation model
View source (opens in a new tab) (opens in new window)Isolation tier mapping
A common pattern is independent isolation per architecture tier:
| Tier | Common choice |
|---|---|
| UI | Shared host name (fully multitenant) |
| API gateway | Shared, with tenant claim in JWT |
| Application services | Shared, tenant context in every request |
| Async queues / topics | Shared topic with tenant_id message attribute, or per-tenant queue |
| Data | Often partitioned: tables with tenant_id (pool); schemas per tenant (bridge); databases per tenant (silo) |
| Object storage | Per-tenant prefix in bucket (pool); bucket per tenant (silo) |
| Search index | Per-tenant routing key (pool); index per tenant (silo) |
The test surface depends on the lowest isolation level in the stack. A fully isolated UI but shared database still requires the full cross-tenant data-leak test battery against the database.
Required test categories per model
| Model | Required test categories |
|---|---|
| Silo / single-tenant | Tenant-to-deployment routing; per-deployment health; deployment automation |
| Pool / fully-shared | Cross-tenant data leak (highest priority); tenant_id propagation; noisy-neighbor mitigation; quota enforcement |
| Bridge / horizontal | Pool tests + database routing per tenant; cross-database query rejection |
| Vertical | Pool + silo tests + tier-migration tests |
The cross-tenant data leak suite is the universal floor: even silo deployments share some surface (account-management APIs, billing, identity providers) where pool-like leaks are possible.
Vitess - keyspace sharding + vindexes
View source (opens in a new tab) (opens in new window)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 type | Use 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 lookup | Secondary 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
| Risk | Why |
|---|---|
Query without tenant_id predicate | Scatters to all shards; returns rows from every tenant |
| Cross-shard transactions | 2PC for cross-shard writes; isolation bugs can occur in rollback paths |
Row updates that change tenant_id | Vitess 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).
- Document access is only tested from inside one tenant
- The tenant isolation suite is green and we do not trust it
- Tenant isolation test plan for the attachments feature