Skip to main content
友田 陽大
Application-layer security
Supabase
RLS
B2B SaaS
セキュリティ
アーキテクチャ設計

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
Reading time
23 min read
Author
友田 陽大
Share

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 and multi-tenant SaaS data-isolation / authorization design, 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.


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.

PathWhat happensRelationship to RLS
A. Gaps in RLSRLS not enabled / a condition omission makes other-tenant rows visibleRLS is "absent / weak"
B. service_role pathThe admin client receives an ID without confirming ownership"Jumps over" RLS
C. Going aroundA view/RPC/function bypasses RLS to read the underlying tableRLS "isn't applied"
D. ID-specifying APITrusts 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). The problems are the two of "forgetting to enable" and "loose conditions."

-- マルチテナントの最小スキーマ
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 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. Correct isolation is set up like this, making tenant membership the source of truth.

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

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

// 修正: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).

-- 危険: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.

-- 危険: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, OWASP API Security Top 10).

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

DesignVerification
What you doWrite correct RLS / ownership checksImpersonate another tenant, hit it, and confirm 0 rows / denial
DeliverablePolicies / codeTests / probe results / CI logs
The question it answers"How do I intend to isolate""Is the isolation really not broken"
How failure appearsCan 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.

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.

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

# ローカル/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."

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

# 自分が所有する環境に対してのみ、非破壊・スコープ固定でプローブする
# --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 / absentThat table is directly queried from a anon/non-admin clientConfirmed exposure (readable through a public API as-is)
RLS is validBut queried with service_role without an ownership checkConfirmed crossing path (RLS doesn't apply)
A view lacks security_invokerThat view is granted to authenticatedExposure 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.

ViewpointWhat to confirm (= presence of evidence)Danger sign
RLS enablementIs RLS enabled on all tenant-derived tablesThere's a table without enable row level security
Tenant conditionDoes the policy constrain by making membership (memberships) the source of truthusing (true), or it stops at per-user and can't constrain the tenant
service_role pathDoes an API using service_role always verify membershipIt receives an ID and queries without an ownership check
Going aroundDo views have security_invoker=on, and RPCs an ownership guardViews/functions aren't recognized as "outside RLS scope"
Crossing regression testDid you pin "even with B's token, empty" with pgTAP/integration testsNo evidence other than "it worked on my machine"
Write denialAre insert/update to another tenant explicitly deniedOnly reads are verified
Dynamic probingDid you run a 2-identity crossing probe in your environmentNot a single record of hitting another tenant at runtime
Regression preventionDo these run on every commit in CITests 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, 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) 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.

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), and when deeper confirmation is needed, provide it as a third-party security 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.


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

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

The vulnerabilities in this article — is your app safe from them?

An expert audit of your Next.js × Supabase authorization & RLS

The IDOR, RLS misconfigurations, and tenant-boundary crossing covered here are vertical risks a library can't fix. I take it on as a security audit — from authorization review through fix design and implementation. You're welcome to visualize the current state with the free OSS first.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading