# Verifying Cross-Tenant Leaks — How to 'Prove' Supabase RLS Isolation (Don't End at Design Alone)

> Explains how to prove, by verification, that 'isolation isn't broken' for the cross-tenant leak where another tenant's data is visible in a multi-tenant SaaS — not just designing RLS. Shows the practice of raising confidence with pgTAP-style regression tests, ownership checks, safe dynamic probing (DAST), and SAST correlation, with vulnerable→verified real code.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, B2B SaaS, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- Cross-tenant leakage (another tenant's data is visible) is one of the most expensive leaks in B2B SaaS. 'Having designed' RLS and isolation 'actually being in effect' are entirely separate facts
- Crossing happens mainly via 4 paths — gaps in RLS, missing ownership checks on the service_role path, going around via JOIN/view/RPC, and ID-specifying APIs (IDOR/BOLA)
- The center of proof is regression tests — hit tenant B's rows with tenant A's token, and pin to CI with pgTAP and integration tests that it always returns 0 rows / is denied
- A static-analysis (SAST) 'suspicion' can be upgraded to a runtime 'confirmation' with safe dynamic probing (DAST) against an environment you own. Weak RLS × non-admin client correlates as a confirmed exposure
- But honestly — neither verification nor audit proves 'completely safe.' They only raise confidence by crushing common holes; residual risk always remains. So you layer design, verification, and human review

---

Let me state the conclusion first. **A multi-tenant SaaS's "tenant isolation" is not protected at all at the point of `having designed RLS`. It becomes protection when you can show by verification that `even if you impersonate another tenant and hit it, not a single row of another tenant's data is returned`.** Design is a declaration of intent, and verification is evidence that that intent isn't broken in the implementation. These two are separate jobs.

And a cross-tenant leak is **one of the most expensive leaks** for a B2B SaaS. It's not one user's information leaking, but an accident where "all of customer company A's data is visible to customer company B, which could be a competitor" — simultaneously breaking contract, trust, and legal. That's exactly why it's worth getting to a state of "I can show it's in effect," not "it's probably in effect."

This article, on the premise that you've built multi-tenancy with Supabase (PostgreSQL + RLS), shows **concrete means to "prove by verification" that isolation isn't broken** — regression tests, ownership checks, safe dynamic probing, and correlation with static analysis — in real code. I leave how to design RLS to [Supabase multi-tenancy design patterns](/blog/supabase-rls-production-multi-tenancy-patterns) and [multi-tenant SaaS data-isolation / authorization design](/blog/multi-tenant-saas-data-isolation-authorization-design-guide), and here consistently handle only "**is the isolation you wrote really in effect**." The overall picture of Supabase × Next.js security is summarized in the [comprehensive guide](/blog/nextjs-supabase-application-security-guide).

---

## 1. The conclusion: tenant isolation isn't protected by "having designed it"

"I put RLS on all tables" is not a basis for reassurance. The reason is simple: **RLS takes effect only "as written," and several paths where RLS doesn't apply coexist with it.** Even with a single-character omission in a policy's condition, even one route using `service_role`, even one view that forgot `security_invoker`, the tenant wall crumbles from there.

What's important here is the property that **"isolation being in effect" can only be confirmed by a positive observation (= I tried to cross and failed).** Looking at the code and thinking "seems correct" is not verification. Verification is

> showing, mechanically, repeatedly, and without regression, that "when you query pointing at tenant A's rows with tenant B's access token, the result is **always empty (0 rows)**."

Only when you satisfy this can you say "the designed isolation isn't broken." Conversely, if you've never once taken this observation, isolation is a "prayer," not a "guarantee."

But let me honestly draw a line here. **Verification raises confidence, but it does not prove the absence of bugs.** Even if you can show the boundaries you tested are protected, you can't guarantee paths you didn't test or paths added in the future. So this article's technique is not "do it and you're completely safe," but a practice for "**mechanically crushing the most likely crossings and continuously keeping confidence high.**" This premise is consistent to the end.

---

## 2. How cross-tenant crossing happens — 4 typical paths

The entrances to crossing seem countless, but what you repeatedly see in the field converges to the next 4 paths. Designing verification with "did I hit all of these 4 paths" as the criterion reduces gaps.

| Path | What happens | Relationship to RLS |
|---|---|---|
| **A. Gaps in RLS** | RLS not enabled / a condition omission makes other-tenant rows visible | RLS is "absent / weak" |
| **B. service_role path** | The admin client receives an ID without confirming ownership | "Jumps over" RLS |
| **C. Going around** | A view/RPC/function bypasses RLS to read the underlying table | RLS "isn't applied" |
| **D. ID-specifying API** | Trusts a tenant ID/object ID as self-reported (IDOR/BOLA) | "Outside" RLS |

### Path A: gaps in RLS — isolation simply "isn't there"

The most basic failure is the case where RLS isn't enabled, or the condition can't constrain the tenant. PostgreSQL's RLS is a design where you enable it per table and add permissions with policies, and **just enabling it with no policy defaults to deny-all (fail-secure)** ([PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)). The problems are the two of "forgetting to enable" and "loose conditions."

```sql
-- マルチテナントの最小スキーマ
create table tenants (
  id   uuid primary key default gen_random_uuid(),
  name text not null
);

-- ユーザーがどのテナントに属するか（所属＝認可の真実の源）
create table memberships (
  user_id   uuid not null references auth.users,
  tenant_id uuid not null references tenants,
  role      text not null default 'member',
  primary key (user_id, tenant_id)
);

create table invoices (
  id         uuid primary key default gen_random_uuid(),
  tenant_id  uuid not null references tenants,
  amount     integer not null,
  created_at timestamptz not null default now()
);

-- 危険：invoices に RLS を張り忘れると、anon キーで全テナントの請求書が読める
--   curl "https://<project>.supabase.co/rest/v1/invoices?select=*" -H "apikey: <anon-key>"
```

This class of misconfiguration materialized in the worst form of "unauthenticated read/write to all tables" in [CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757) registered in 2025 (insufficient RLS lets a remote unauthenticated attacker read/write arbitrary tables, CWE-863, CVSS 9.3 CRITICAL). The discovery technique for RLS misconfiguration itself is written in detail in the [RLS misconfiguration detection / audit guide](/blog/supabase-rls-misconfiguration-detection-audit-guide). Correct isolation is set up like this, making tenant membership the source of truth.

```sql
alter table memberships enable row level security;
alter table invoices    enable row level security;

-- ユーザーは自分の所属だけ読める
create policy "read own memberships"
on memberships for select
to authenticated
using ( user_id = (select auth.uid()) );

-- 請求書は「自分が所属するテナントの行」だけ読める
create policy "read invoices of my tenants"
on invoices for select
to authenticated
using (
  tenant_id in (
    select tenant_id from memberships
    where user_id = (select auth.uid())
  )
);
```

Wrapping `auth.uid()` with `(select auth.uid())` is a Supabase official recommendation, to avoid per-row re-evaluation and keep performance on large tables ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)). Up to here is "design." Whether this is in effect, we "verify" from Section 4 onward.

### Path B: forgetting the ownership check on the service_role path

Supabase's `service_role` key has PostgreSQL's `BYPASSRLS` and **completely jumps over RLS**. The official docs also state clearly that "service_role bypasses RLS. Use it only on the server side" ([Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)). Even if you perfectly set up Path A's RLS, if there's one route using `service_role`, RLS is disabled at just that spot.

```ts
// app/api/invoices/route.ts — 脆弱：service_role でテナントIDを"自己申告"のまま信じる
import { createClient } from "@supabase/supabase-js";

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // ← RLS をバイパスする管理クライアント
);

export async function POST(req: Request) {
  const { tenantId } = await req.json(); // ← クライアントが自由に指定できる値

  // このユーザーが tenantId に所属しているか一切確認していない＝テナント越境
  const { data } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("tenant_id", tenantId);

  return Response.json(data); // 他テナントの請求書がそのまま返る
}
```

The whereabouts and leakage risk of the `service_role` key itself are handled in the [anon-key / service_role-key exposure guide](/blog/supabase-anon-key-service-role-key-exposure-guide). The principle of the fix here is just two — **(1) if possible, don't use service_role, and entrust authorization to RLS with an anon client that runs on the user's token. (2) If service_role is unavoidable, always confirm membership on the server before querying.**

```ts
// 修正：service_role を使うなら、テナント所属をサーバーで必ず検証する
export async function POST(req: Request) {
  const { tenantId } = await req.json();

  const user = await getAuthenticatedUser(req); // クライアントの自己申告を信じない
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

  // 所属を確認：このユーザーが tenantId のメンバーでなければ越境として弾く
  const { data: membership } = await supabaseAdmin
    .from("memberships")
    .select("tenant_id")
    .eq("user_id", user.id)
    .eq("tenant_id", tenantId)
    .maybeSingle();
  if (!membership) return Response.json({ error: "forbidden" }, { status: 403 });

  const { data } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("tenant_id", tenantId);
  return Response.json(data);
}
```

### Path C: going around via JOIN / view / RPC

RLS takes effect on tables, but it's easy to overlook that **there are paths that "go around" it**. There are two representatives.

One is the **view**. PostgreSQL views run by default with the view owner's permissions, and the querying user's RLS isn't applied. Since Postgres 15, attaching `security_invoker = on` applies the querying user's permissions (= RLS) ([PostgreSQL Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)).

```sql
-- 危険：security_invoker を付けないビューは、所有者権限で下層を読む＝RLS回避
create view invoice_totals as
  select tenant_id, sum(amount) as total
  from invoices
  group by tenant_id;
-- このビューを authenticated に grant すると、全テナントの集計が誰にでも見える

-- 修正：問い合わせユーザーの権限（＝RLS）を適用させる
create view invoice_totals
  with (security_invoker = on)
  as
  select tenant_id, sum(amount) as total
  from invoices
  group by tenant_id;
```

The other is the **`SECURITY DEFINER` function / RPC**. Because it runs with the definer's permissions, if you don't confirm ownership inside the function, you can read/write, jumping over RLS, for any `tenant_id` passed as an argument.

```sql
-- 危険：SECURITY DEFINER が任意 tenant_id を所有権チェックなしで受ける
create function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
security definer            -- 定義者権限で実行＝RLSを飛び越える
set search_path = ''        -- search_path 固定は必須（権限昇格対策）
as $$
  select * from public.invoices where tenant_id = p_tenant_id
$$;

-- 修正案1：問い合わせユーザーのRLSを効かせる（多くの場合これで十分）
create or replace function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
stable
security invoker
set search_path = ''
as $$
  select * from public.invoices where tenant_id = p_tenant_id
$$;

-- 修正案2：どうしても definer が必要なら、関数内で所属を必ず確認する
--   where exists (select 1 from public.memberships
--                 where user_id = auth.uid() and tenant_id = p_tenant_id)
```

Because the going-around path enters precisely into the blind spot of the thought-stopping "I put up RLS so it's safe," it's important in verification to **always include views and RPCs as targets of "tenant crossing."**

### Path D: ID-specifying APIs (IDOR / BOLA)

The last is the path that **trusts a tenant ID / object ID as the client's self-report.** This is exactly **API1:2023 Broken Object Level Authorization (BOLA)**, ranked #1 ever since 2019 in the OWASP API Security Top 10 ([OWASP API1:2023 BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/), [OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)).

```text
POST /api/invoices  { "tenantId": "AAAA..." }  ← 自分のテナント（正規）
POST /api/invoices  { "tenantId": "BBBB..." }  ← IDを差し替えるだけ。これが通れば越境
```

It's contiguous with Path B, but what I want to emphasize is that **"safe because the ID isn't shown on screen" doesn't hold.** Server Actions (`"use server"`) too are effectively POST endpoints, and the argument's ID can be arbitrarily swapped over HTTP. How IDOR is born and fixed in Supabase is detailed in the [IDOR / authorization-flaw detection guide](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide). In this article, let me move the discussion to whether you can "**plug all 4 of these paths by verification.**"

---

## 3. "Design" and "verification" are entirely separate jobs

Confuse this and you'll never be able to prove isolation. Let me clearly separate the two jobs.

| | Design | Verification |
|---|---|---|
| What you do | Write correct RLS / ownership checks | Impersonate another tenant, hit it, and confirm 0 rows / denial |
| Deliverable | Policies / code | Tests / probe results / CI logs |
| The question it answers | "How do I intend to isolate" | "Is the isolation really not broken" |
| How failure appears | Can be noticed in review (sometimes) | **Only known by running it** |

Design is a statement of intent, and you can raise its quality with review. But **whether the intent isn't broken in the implementation can only be observed by running it.** Even if a condition's `=` became `<>`, or the `where` constraining `tenant_id` disappeared, the code works normally and no error appears. This is because crossing is the kind of defect that "runs quietly even when broken."

So the core of verification is positive observation. Concretely, confirm the next invariant for all major tables and paths.

> **Invariant: when you request tenant A's rows with tenant B's principal (JWT / access token), the result is always empty, and writes are always denied.**

"Confirmed once" of this invariant is not enough. Since the schema, policies, and code keep changing, you need to show **continuously that it doesn't regress.** The next section's regression tests are precisely the means to pin this invariant to CI.

---

## 4. Verification technique ①: regression tests — reproduce crossing "automatically" and confirm denial

Regression tests are the center of cross-tenant verification. The aim is "**show, without a human, on every commit, that crossing isn't reproduced.**" Write them in two layers — pgTAP that completes inside the DB, and integration tests that hit it over the API. The two have different coverage, so both are worth doing. For the foundation of RLS regression testing with pgTAP, also see the [RLS policy regression-testing guide with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide).

### 4-1. Test RLS policies directly inside the DB with pgTAP

pgTAP is a testing framework that runs inside PostgreSQL and can verify **the truth of RLS policies at the layer closest to the DB.** In Supabase, you forge the authentication context with `request.jwt.claims` and `role` settings, and query as that user. `auth.uid()` reads this claim's `sub`.

```sql
-- supabase/tests/tenant_isolation.test.sql
begin;
select plan(5);

-- 1) 固定IDでフィクスチャを用意（テナントA・B と、それぞれの所属ユーザー）
insert into tenants (id, name) values
  ('11111111-1111-1111-1111-111111111111', 'Tenant A'),
  ('22222222-2222-2222-2222-222222222222', 'Tenant B');

insert into auth.users (id, email) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'a@example.com'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'b@example.com');

insert into memberships (user_id, tenant_id) values
  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111'),
  ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222');

insert into invoices (id, tenant_id, amount) values
  ('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 1000), -- Aの請求書
  ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 2000); -- Bの請求書

-- 2) テナントBのユーザーになりきる
set local role authenticated;
set local request.jwt.claims to
  '{"sub":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","role":"authenticated"}';

-- 3) 不変条件①：Bのユーザーから、Aのテナントの行は1件も見えない
select is(
  (select count(*)::int from invoices
   where tenant_id = '11111111-1111-1111-1111-111111111111'),
  0,
  'テナントBのユーザーはテナントAの請求書を読めない'
);

-- 4) 不変条件②：Bのユーザーには自分のテナントの行だけが見える
select is(
  (select count(*)::int from invoices),
  1,
  'テナントBのユーザーに見えるのは自テナントの1件だけ'
);

-- 5) 不変条件③：Aの行へのIDピンポイント参照も空
select is_empty(
  $$ select * from invoices where id = 'a0000000-0000-0000-0000-000000000001' $$,
  'IDを直接指定してもテナントAの行は取得できない'
);

-- 6) 不変条件④：Aのテナントへの書き込みはRLSで拒否される
select throws_ok(
  $$ insert into invoices (tenant_id, amount)
     values ('11111111-1111-1111-1111-111111111111', 9999) $$,
  '42501', -- insufficient_privilege（RLS の WITH CHECK 違反）
  null,
  'テナントBのユーザーはテナントAへ請求書を作成できない'
);

-- 7) 不変条件⑤：回り込みビューも越境しない（security_invoker=on の確認）
select is(
  (select count(*)::int from invoice_totals
   where tenant_id = '11111111-1111-1111-1111-111111111111'),
  0,
  'ビュー経由でもテナントAの集計は見えない'
);

select * from finish();
rollback;
```

Three points. **(1)** Wrapped with `begin … rollback`, the test leaves no side effects. **(2)** Write denial expects error code `42501` with `throws_ok`, confirming "explicit denial," not "quietly 0 rows." **(3)** By including views (Path C) as targets too, it catches going-around regressions. If hand-assembling `request.jwt.claims` is cumbersome, you can write the same concisely with helpers like `supabase_test_helpers`' `tests.authenticate_as()`.

In CI, run it from the Supabase CLI.

```bash
# ローカル/CIでRLS回帰テストを実行（DBをリセットしてからテスト）
supabase db reset
supabase test db
```

### 4-2. Integration tests: hit it over the API with a real token

pgTAP confirms the truth inside the DB, but **it doesn't test the app's code (Paths B and D).** A Route Handler that forgot the ownership check with `service_role` crosses even if the DB's RLS is perfect. So separately have an integration test that "goes to cross the API with an actually-issued access token."

```ts
// tests/tenant-isolation.test.ts — 実トークンでAPI越しにテナント越えを試す
import { createClient } from "@supabase/supabase-js";
import { beforeAll, expect, test } from "vitest";

const URL = process.env.SUPABASE_URL!;
const ANON = process.env.SUPABASE_ANON_KEY!;
const TENANT_A = "11111111-1111-1111-1111-111111111111";

let tokenB: string; // テナントBのユーザーのアクセストークン

beforeAll(async () => {
  const sb = createClient(URL, ANON);
  const { data, error } = await sb.auth.signInWithPassword({
    email: "b@example.com",
    password: process.env.TEST_USER_B_PASSWORD!, // テスト専用アカウントの資格情報
  });
  if (error) throw error;
  tokenB = data.session!.access_token;
});

// Bのトークンで動くクライアント＝RLSが効く。越境していなければ必ず空。
function asTenantB() {
  return createClient(URL, ANON, {
    global: { headers: { Authorization: `Bearer ${tokenB}` } },
  });
}

test("PostgREST: BのトークンでAの請求書は0件", async () => {
  const { data, error } = await asTenantB()
    .from("invoices")
    .select("*")
    .eq("tenant_id", TENANT_A);
  expect(error).toBeNull();
  expect(data).toEqual([]); // 越境していなければ空
});

test("REST API: Bのトークンで tenantId=A を要求しても漏れない", async () => {
  const res = await fetch("http://localhost:3000/api/invoices", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${tokenB}`,
    },
    body: JSON.stringify({ tenantId: TENANT_A }), // 他テナントIDを自己申告（経路D）
  });
  // 403 で弾く実装が理想。最低でも本文に他テナントの行が含まれないこと。
  expect([401, 403, 404]).toContain(res.status);
  const body = await res.text();
  expect(body).not.toContain(TENANT_A);
});
```

Combine these two layers and you can stretch the invariant over **both "the DB's RLS" and "the app's ownership check."** With only one or the other, a hole always remains in Section 2's 4 paths.

> Pass the test account's credentials via environment variables (Secrets), and leave them neither in code nor logs. Even though it's a test, you handle a real token, so a leak here has the same weight as a production key leak.

---

## 5. Verification technique ②: upgrade "suspicion" to "confirmation" with safe dynamic probing (DAST)

Regression tests protect "the invariants you wrote yourself." On the other hand, **paths you forgot to write** — a newly added endpoint, an RPC you didn't write a test for, a view you overlooked — aren't in the regression-test net from the start. What supplements this is dynamic probing (DAST).

The idea is exactly the practice of "authorization testing" shown by OWASP's [Web Security Testing Guide](https://owasp.org/www-project-web-security-testing-guide/). **Prepare two identities (tenant A and B), and with one principal hit pointing at the other's resources, and observe the response.** Run this mechanically across endpoints.

But there are strict ethical and safety rules here.

- **The target is only an environment you own / manage** (staging recommended, not production). Probing others' environments is an attack.
- **Non-destructive**: read-centric. Limit write probes to a rollback-able scope / a dedicated tenant.
- **Fixed scope**: explicitly narrow the target hosts/paths, preventing spillover to the unexpected.

Static analysis (SAST) goes only as far as raising a **suspicion** of "this might not be narrowed by ownership." Dynamic probing turns that suspicion into the **runtime fact of whether it leaked, by actually becoming another tenant and hitting it.** In the OSS I publish, you can run this correlation with one command.

```bash
# 自分が所有する環境に対してのみ、非破壊・スコープ固定でプローブする
# --correlate で静的解析の「疑い」と実行時の「確証」を突き合わせる
npx @aegiskit/cli probe https://staging.example.com --correlate
```

To say it without exaggeration, this is not magic. What you can probe is only paths that "can be hit and observed," and crossings deep within a complex business flow need human test design. **DAST is a confirmation-upgrade device, not a guarantee of comprehensiveness** — this dividing line is completely contiguous with the audit discussion in Section 8.

---

## 6. "Weak RLS" × "non-admin client" = correlates as a confirmed exposure

The key to raising verification accuracy is **matching separate pieces of evidence to make a "confirmation."** Information that's a "suspicion" alone can be upgraded to a "confirmed exposure" when combined. The most effective correlation is the next set.

| Evidence ① (static: SQL side) | Evidence ② (static: code side) | Correlation conclusion |
|---|---|---|
| A table's RLS is weak / absent | That table is directly queried from a **anon/non-admin client** | **Confirmed exposure** (readable through a public API as-is) |
| RLS is valid | But queried with **service_role** without an ownership check | **Confirmed crossing path** (RLS doesn't apply) |
| A view lacks `security_invoker` | That view is granted to authenticated | **Exposure via going around** |

The value of this correlation is in being able to **prioritize without increasing noise.** "RLS is weak" alone can't be concluded — it might be an internal-only table. "Reading from a non-admin client" alone — RLS might be protecting it. But **a spot where both hold simultaneously** must, in principle, leak. Layer Section 5's dynamic probing of "actually leaked" on top of this, and you have triple evidence — **static suspicion → structural confirmation → runtime confirmation** — and you can handle it without hesitation as a defect to fix with top priority.

Conversely, a standalone warning that can't be correlated can be deferred. The biggest cause of burnout in verification is the state where "everything is equally scary," so **putting an order with correlation** is a practical lifeline that makes verification sustainable.

---

## 7. Cross-tenant verification checklist (for B2B)

Whether outsourced, in-house, or AI-generated, before going to production, confirm "could you show isolation isn't broken" from these viewpoints. Even from the buyer's standpoint, throwing these questions to the developer reveals whether verification is built into the design.

| Viewpoint | What to confirm (= presence of evidence) | Danger sign |
|---|---|---|
| **RLS enablement** | Is RLS enabled on all tenant-derived tables | There's a table without `enable row level security` |
| **Tenant condition** | Does the policy constrain by making membership (memberships) the source of truth | `using (true)`, or it stops at per-user and can't constrain the tenant |
| **service_role path** | Does an API using service_role always verify membership | It receives an ID and queries without an ownership check |
| **Going around** | Do views have `security_invoker=on`, and RPCs an ownership guard | Views/functions aren't recognized as "outside RLS scope" |
| **Crossing regression test** | Did you pin "even with B's token, empty" with pgTAP/integration tests | No evidence other than "it worked on my machine" |
| **Write denial** | Are insert/update to another tenant explicitly denied | Only reads are verified |
| **Dynamic probing** | Did you run a 2-identity crossing probe in your environment | Not a single record of hitting another tenant at runtime |
| **Regression prevention** | Do these run on every commit in CI | Tests exist but only run manually |

The two most effective questions from the buyer's viewpoint are **"What happens if you hit tenant A's data with tenant B's token?" "Is there a test / log that shows that result?"** A good developer answers immediately not with an explanation of the design but with **evidence of verification.** I myself, in [developing a B2B subscription SaaS](/case-studies/lumber-industry-dx), have built on the premise that tenant isolation gets into operation only by continuously showing not a "blueprint" but "hitting the crossing and it not failing."

---

## 8. Self-verification and third-party audit — up to where does it become "proof"

All the techniques so far are ones **you (your team) can run.** Build regression tests and dynamic probing into CI, and confidence rises greatly. Hardening here first is best by cost-effectiveness.

On top of that, let me honestly position the value of a third-party audit too. **Your own verification has the structural blind spot of "it only hits the paths you assumed."** A path the designer assumed "shouldn't cross here" isn't included in tests or probes from the start. A third party goes to hit the outside of that assumption — an unanticipated combination of RPCs, an invitation flow spanning multiple tenants, the edge of a shared resource — from threat modeling. This is also why OWASP API1:2023 BOLA ([OWASP API1:2023 BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)) keeps being called "the most frequent and the most overlooked."

But here too, let me honestly draw a line. **A third-party audit also doesn't prove "completely safe."** What an audit can show goes only as far as the fact that "in the defined scope and period, with the techniques used, this many crossing paths were / weren't found." It's not a guarantee against out-of-scope, out-of-technique, or future changes. So it's correct to treat the audit result not as a "seal of approval" but as something that **raises confidence one notch and makes residual risk explicit.** What an audit covers up to, what it doesn't guarantee, and when you should request one are organized in the [security-audit scope guide](/blog/nextjs-supabase-security-audit-scope-when-needed-guide).

To organize, the "proof" of tenant isolation is staged.

1. **Design**: write RLS + ownership checks (= a declaration of intent)
2. **Regression tests**: protect your invariants in CI without regression (= raise confidence)
3. **Dynamic probing**: upgrade suspicion to runtime confirmation (= raise confidence further)
4. **Third-party audit**: hit unanticipated blind spots from outside (= raise confidence one more notch, make residual risk explicit)

No stage reaches "complete," but **the more you layer, the more certainly the probability of crossing drops, and the faster you can notice it when it happens.** I automate stages 2–3 with my own security tool suite ([Aegis / `npx @aegiskit/cli`](/aegis)), and when deeper confirmation is needed, provide it as a [third-party security audit](/aegis/audit). Building fast with AI itself is correct. **Getting to a state where you can show, with evidence, that what you built fast isn't broken in isolation** — if you need that mechanism-building or a tenant-isolation review of an existing app, feel free to consult me.

---

## Frequently asked questions (FAQ)

**Q. If I put RLS on all tables, can I prevent cross-tenant crossing?**
A. It's a necessary condition, but not sufficient. As in Section 2, the service_role path (B), going around via views/RPCs (C), and the ID-self-reporting API (D) are outside / bypass paths of RLS. "Having put it up" and "being in effect" are different, so it becomes protection only once you show by regression tests and dynamic probing that "crossing isn't reproduced."

**Q. With pgTAP regression tests, aren't integration tests unnecessary?**
A. No. pgTAP confirms the truth of RLS inside the DB, but doesn't go through the app's code (the service_role path or a forgotten ownership check). Conversely, integration tests go through the code, but it's hard to comprehensively cover fine policy conditions inside the DB. The coverage differs, so having both is correct.

**Q. May I run dynamic probing (DAST) against production?**
A. In principle, do it in an environment you own / manage and where data being broken is no problem, like staging. Against production, do it cautiously after strictly managing the impact scope, non-destructiveness, and scope. Probing others' environments, done without permission, constitutes an attack.

**Q. If everything passes verification, may I "declare it safe"?**
A. You must not. What verification shows goes only as far as the fact "crossing wasn't reproduced at the tested boundaries," and doesn't guarantee untested paths or future changes. It's honest, and ultimately trusted, to state "the range confirmable at this point" after making residual risk explicit.

**Q. Which is better, the design of "putting the tenant ID in a JWT custom claim" or "looking it up in a memberships table"?**
A. Both are used in practice. Putting it in the claim is fast but requires designing for reflecting membership changes and resistance to claim tampering. Looking it up in a table is always latest and easy to audit but adds subqueries to policies. What matters more than the choice itself is **being able to show by verification that crossing doesn't happen with the chosen design.** For a comparison of designs, see the [multi-tenant isolation / authorization design guide](/blog/multi-tenant-saas-data-isolation-authorization-design-guide).

---

## Summary: isolation becomes protection only at "could show it's not broken," not "wrote it"

Let me organize the key points.

- A cross-tenant leak is one of the most expensive leaks in B2B SaaS. **"Having designed" RLS and isolation "being in effect" are separate facts.**
- Crossing happens via 4 paths — **gaps in RLS (A) / ownership leakage on the service_role path (B) / going around via views and RPCs (C) / ID-specifying API = IDOR / BOLA (D).** Design verification by whether you hit all 4 of these paths.
- The core of proof is **pinning the invariant** "request tenant A's rows with tenant B's principal and it's always empty, writes always denied" **to CI with pgTAP and integration tests.** Confirm not only reads but write denial.
- A static-analysis **suspicion** can be upgraded to **confirmation with safe dynamic probing (DAST) against an environment you own.** **Weak RLS × non-admin client** correlates as a confirmed exposure.
- And honestly — **neither verification nor third-party audit proves "completely safe."** They're an endeavor to raise confidence stepwise and make residual risk explicit. So you layer design, tests, probing, and human review.

Isolation becomes protection not the moment you "wrote it" in code, but the moment you **could show it's "not broken."** Not a blueprint, but evidence of hitting the crossing and it not failing — that's the only way to turn tenant isolation from a "prayer" into a "guarantee."

---

## References

- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization (BOLA)](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
- [OWASP API Security Top 10 (2023 edition, overall)](https://owasp.org/API-Security/editions/2023/en/0x11-t10/)
- [OWASP Web Security Testing Guide (the practice of authorization testing)](https://owasp.org/www-project-web-security-testing-guide/)
- [Supabase Docs — Row Level Security (service_role bypasses RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL — Row Security Policies (the relationship of views / SECURITY DEFINER and RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [NVD — CVE-2025-48757 (unauthenticated access via insufficient RLS, CWE-863, CVSS 9.3)](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
