cross-tenant-data-leak-tests

Use when designing or adding tenant isolation tests to a multi-tenant app, adding a CI leak gate, or investigating a leak. Inventories tenant-bearing surfaces, then tests read-by-ID, list leaks, spoofed tenant IDs, JWT replay, storage IDOR, search, jobs and caches, plus Postgres row-level security.

/plugin install qa-multi-tenancy@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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:

  • Each test fixtures two disjoint tenants (A, B).
  • Each test exercises one (surface, pattern) cell.
  • Each test asserts denial of cross-tenant access.
  • The suite runs as part of CI on every PR.
  • A single failure blocks merge.

When to use

  • Designing a multi-tenant test suite for a new feature or auditing coverage for an existing tenant boundary (planning section).
  • Implementing the leak-test suite from the coverage matrix.
  • Adding the CI gate to an existing multi-tenant project.
  • Investigating a leak finding - reproduce with a minimal test.
  • Adding coverage for a newly-introduced tenant-bearing surface.

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 categoryExamplesHow to find
Database tablestables with tenant_id columngrep -r "tenant_id" --include="*.sql" ; ORM model field annotations
API endpointsroutes returning tenant dataroute registrations grep
Object storagebuckets / prefixes per tenantIaC for buckets, lifecycle config
Search indicestenant-routed Elasticsearch / Algoliaindex-naming scheme
Async messagestenant_id in message attributes / payloadmessage-class definitions
CachesRedis keys with tenant_id prefixcache-client wrappers
Logs / metricslog lines containing tenant_idlog-emit grep
Background jobsSidekiq / Celery tasks taking tenant_idtask definitions
Reports / exportstenant-scoped reportsexport endpoints
Webhooks / outboundtenant-routed external callswebhook 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   |   -  |   -  |  -  | -  |   -   |  X

Generate 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 testResult
SuperuserBypasses RLS - tests pass, prod leaks. Do not use.
Role with BYPASSRLS attributeSame 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=f

For 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 disclosure

Convention: 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 ids

Even 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.text

Per 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 == 403

The 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_id

Test 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 -v

Both 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:

  1. Read the assertion - which surface + pattern leaked?
  2. Check the connection role - SELECT current_user; in the test. If it's superuser, the test was bypassed. Fix the test connection first.
  3. Reproduce locally with the same role.
  4. Check the policy - is there a policy on the table? Is RLS enabled? Is FORCE ROW LEVEL SECURITY set?
  5. Check the application path - is tenant_id derived from the session, or from request payload?
  6. Write the minimal regression test before fixing.

Anti-patterns

Anti-patternWhy it failsFix
Tests connect as superuserRLS bypassed; tests pass falselyUse prod-equivalent role
Migrations and tests use same roleMigrations need DDL; tests should notTwo roles: migrations user (owner), app user (RLS-bound)
Test setup creates tenants via direct SQL bypassing RLSOK in setup, but reset tenant context before assertionsSET LOCAL between fixture creation and test body
403 used instead of 404 for unauthorised cross-tenant resourcesLeaks existenceUse 404 (and document)
Single test for "tenant isolation works"Insufficient coverageOne test per (surface, pattern) cell
Skipping FK/UNIQUE side-channel testsReal production bugs hide hereAlways test FK + unique cross-tenant
No async-job context-reload testJob runners often trust payloadAlways test
Cache without tenant prefixTenant A and B alias same logical keyVerify key generation in test
Suite not part of CICatches nothingBlock merge on failure

Limitations

  • Cannot test side-channel timing. Unique-constraint timing differences are detectable by an attacker but expensive to unit-test reliably. Constant-time response or rate-limit layers are the prod defence.
  • Cannot test all routes. New routes added without isolation tests are still vulnerable. Trace tenant_id propagation through new routes to enforce coverage on PR.
  • Cannot detect log leaks. Tests verify response payloads, not log lines. Add log-grep tests separately.
  • Test database must mirror prod RLS setup. If test DB uses a different connection model, prod-only bugs slip through.

References

OWASP WSTG-ATHZ-02 scenarios

Three primary authorization-bypass scenarios apply to every pool/bridge surface:

PatternWhatSurface
Horizontal escalationTenant A accesses tenant B's data at identical privilegeAll pool/bridge surfaces
Vertical escalationNon-admin in tenant A accesses admin-only resourcesAll admin-scoped surfaces
IDOR / BOLADirect reference attack - change an ID in URL/payloadAll ID-bearing endpoints

Tenant-isolation-specific patterns

PatternTest
tenant_id from request payloadSend tenant A's session with tenant_id=B in body - must reject
Missing tenant_id filter in new endpointEnumerate routes added in last N commits; verify each filters by tenant
Cross-tenant via foreign keyCreate FK from tenant-A row to tenant-B row - must fail
Cross-tenant via unique constraintInsert tenant-A row with a key that exists in tenant B - observe error timing as a side channel
JWT replay across tenantsTenant A's JWT used to call tenant B's endpoint - must reject on signature/iss/aud check
Object storage path traversalTenant A presigned URL -> modify prefix to tenant B's - must 403
Search query without tenant filterDirect search index query - must include the tenant routing key
Async job tenant contextJob enqueued by tenant A -> executor must reload tenant context, not trust the message
Cache key collisionTenant A and tenant B have the same logical key - cache must namespace
Log scrubbingTenant 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:

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).

Reference

CREATE POLICY syntax reference

View source (opens in a new tab) (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

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

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

  • Designing the tenant-isolation model for a new B2B SaaS product or feature.
  • Auditing an existing model - does the testing surface match the declared isolation level?
  • Choosing what to test: each model creates a distinct set of failure modes the test suite must cover.
  • PR review of architecture changes that move components along the isolation continuum.

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

ModelComputeDataNetworkCost/tenantBlast radiusNoisy neighbor
Automated single-tenant (silo)DedicatedDedicatedDedicatedHighestOne tenantNone
Fully multitenant (pool)SharedShared (tenant_id discriminator)SharedLowestAll tenantsHigh
Horizontally partitioned (bridge)SharedDedicated per tenantSharedMediumData isolatedData tier: none
Vertically partitionedMixMixMixMixedPer-tierPer-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:

  • Identity context - tenant_id in JWT claims (auth.jwt() in Supabase per supabase.com/docs/guides/database/postgres/row-level-security (opens in new window)) or AWS Cognito ID token; the source of truth for "who is this request for".
  • Authorisation policy - Postgres Row-Level Security per row-level-security.md (opens in new window), AWS IAM dynamic policies generated per tenant, application-level authorisation middleware.
  • Resource ABAC tags - tag each tenant resource with tenant-id=<x>, then enforce via IAM condition keys.
  • Network segmentation - per-tenant VPCs / subnets / security groups (silo only).
  • Encryption keys - per-tenant KMS keys (silo / bridge); useful for crypto-shredding on tenant offboarding.

Anti-patterns

Anti-patternWhy it failsFix
tenant_id filter only in application codeOne missed query path = cross-tenant leakPush the filter to the database (RLS) or row-attribute IAM
tenant_id from request header / bodySpoofable; tenant A can claim to be tenant BAlways derive tenant_id from authenticated JWT/session, never from request payload
Trust the JWT raw_user_meta_data for tenant claimsUser-modifiable per Supabase docsUse raw_app_meta_data (server-set) or a server-side claim store
Single connection pool for all tenantsOne slow tenant query blocks allPer-tenant pools, or quota-aware pools
Shared object-storage bucket without prefix isolationObject enumeration leaks across tenantsPer-tenant prefix + IAM condition on the prefix
No isolation tests in CIModels drift over timeCross-tenant leak tests in every PR per cross-tenant-data-leak-tests
Migration scripts run without tenant contextSchema changes touch all tenants at once; high blast radiusStamp 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

  • No model is leak-proof by construction. Silo defends against most cross-tenant leaks but inherits leak risk in any shared management surface (admin UI, billing). RLS defends the DB but not application caches.
  • Cost vs isolation is a real trade-off. Per Microsoft, if a single tenant requires a given infrastructure cost, 100 tenants in pure silo require roughly 100 times that cost.
  • Compliance scope. Some regulators (e.g., FedRAMP High, certain healthcare regimes) effectively mandate silo for certain data classifications. Check counsel-of-record before assuming pool is acceptable.
  • Azure subscription / AWS account limits. Shared infrastructure reaches account-level scale limits faster than silo.

References

Reference

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)

PropertyValue
ComputeDedicated per tenant
DataDedicated per tenant
NetworkDedicated per tenant
Cost per tenantHighest
Blast radiusOne tenant
Noisy neighborNone

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)

PropertyValue
ComputeShared
DataShared (single DB with tenant_id discriminator)
NetworkShared
Cost per tenantLowest
Blast radiusAll tenants
Noisy neighborHigh

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)

PropertyValue
ComputeShared
DataDedicated per tenant
NetworkShared
Cost per tenantMedium
Blast radiusApp-tier shared, data isolated
Noisy neighborApp-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

PropertyValue
ComputeMix (some tenants dedicated, others shared)
DataMix
NetworkMix
Cost per tenantMixed
Blast radiusPer-tier decision
Noisy neighborPer-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).

Reference

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:

  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).

Reference

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

  • 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

Reference

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:

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

Reference

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

  • Designing tenant isolation on a Postgres-backed pool / bridge model (for MySQL / MariaDB, CockroachDB, Vitess, or SQL Server see other-engines.md (opens in new window)).
  • Auditing existing RLS policies for correctness.
  • Writing tests that verify RLS denies cross-tenant access (per the main skill).
  • 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 tenant-context-patterns.md (opens in new window) and confirm the app sets it per transaction (SET LOCAL) from an authenticated session, never from request input.
  3. Write the tenant policy using create-policy-syntax.md (opens in new window) and a shape from policy-and-bypass-patterns.md (opens in new window) - 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: 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:

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 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

  • 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

Reference

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

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).

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

Reference

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:

TierCommon choice
UIShared host name (fully multitenant)
API gatewayShared, with tenant claim in JWT
Application servicesShared, tenant context in every request
Async queues / topicsShared topic with tenant_id message attribute, or per-tenant queue
DataOften partitioned: tables with tenant_id (pool); schemas per tenant (bridge); databases per tenant (silo)
Object storagePer-tenant prefix in bucket (pool); bucket per tenant (silo)
Search indexPer-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

ModelRequired test categories
Silo / single-tenantTenant-to-deployment routing; per-deployment health; deployment automation
Pool / fully-sharedCross-tenant data leak (highest priority); tenant_id propagation; noisy-neighbor mitigation; quota enforcement
Bridge / horizontalPool tests + database routing per tenant; cross-database query rejection
VerticalPool + 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.

Reference

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 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).

  • 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