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

Detecting Supabase RLS Misconfigurations — Surfacing Not-Enabled, Missing WITH CHECK, USING(true), and Over-Granted anon from Your Migrations

Supabase RLS opens holes by 'thinking you enabled it.' A practical guide to surfacing and plugging the dangerous patterns — RLS not enabled, missing WITH CHECK, USING(true), over-granted anon, and SECURITY DEFINER without a fixed search_path — with static verification of supabase/migrations/**.sql.

Published
Reading time
25 min read
Author
友田 陽大
Share

Let me state the conclusion first. Supabase's row-level security (RLS) opens holes by "thinking you configured it." And the ways those holes open don't have infinite variations; they converge to a finite, fixed set of patterns — forgetting to enable RLS, unconditional allows like USING (true), missing WITH CHECK, over-granting permissions to the anon role, and SECURITY DEFINER functions that don't fix search_path. These five.

And what's important is that these can be mechanically surfaced just by statically reading supabase/migrations/**.sql. When you hear "vulnerability scanning," you might imagine "actually attacking and trying," but most RLS misconfigurations can be judged "this is dangerous" at the point of syntactically parsing the SQL DDL (the definitions of tables, policies, and functions). This article shows the catalog of those dangerous patterns, why they're mass-produced, and how to auto-detect them from migrations, with vulnerable→fixed real SQL.

But at the end, let me draw an honest line. Even if "detection" can be mechanized, "correct RLS design" can't. Who owns what, and where you draw the tenant boundary — these are design decisions specific to your business domain, and the human audit domain. What a tool can say goes only as far as "you didn't step on a common trap," not "your authorization is correct." Not confusing these is the thing I most want to convey in this article.

Note this article is part of a series on authorization. The overall security picture of Next.js × Supabase is in the comprehensive guide, key handling is in anon-key / service_role-key exposure, and reading others' data by rewriting an ID key — IDOR/BOLA — is each split out. This article narrows to "misconfigurations of the RLS policies themselves."


1. Premise: in Supabase, "create a table" is synonymous with "publish an API"

To understand why RLS misconfiguration is fatal, you need to grasp just one thing about Supabase's architecture.

Supabase automatically publishes public-schema tables as a REST API via PostgREST. The instant you create one table, an endpoint https://<project>.supabase.co/rest/v1/<table> sprouts. And this API can be hit with the anon key shipped to the browser. The anon key is not secret information but a public key embedded in the client (Supabase: API keys).

That is, the causation is like this.

CREATE TABLE しただけ        →  REST API が自動で生える
RLS を有効化していない        →  anon キーでそのAPIが素通り
anon キーは公開情報           →  実質「誰でも全件読める」

What kicks in here is PostgreSQL's spec. RLS is disabled unless you explicitly enable it per table. CREATE TABLE doesn't enable RLS. Only after separately running ALTER TABLE ... ENABLE ROW LEVEL SECURITY does row control begin (PostgreSQL: Row Security Policies). Create it in the Supabase dashboard's Table Editor and RLS is enabled by default, but write create table in an SQL migration and it isn't enabled — this asymmetry is the biggest supplier of misconfigurations.

The Supabase official docs also state clearly that "if you build on Postgres, you should enable RLS on all tables to protect your data" (Supabase: Row Level Security). Conversely, enabling is not "something done for you" but "something you write."

Under this premise, let me break down the dangerous patterns one by one. Each pattern is shown in the order "what happens → vulnerable SQL → fixed SQL."


2. Dangerous pattern ①: RLS not enabled (forgetting ENABLE ROW LEVEL SECURITY)

The most common and most fatal is this. Publishing a table while leaving RLS unenabled.

What happens

As in Section 1, a table without RLS can be read in full with the anon key. With a personal-information table like profiles in this state, names, emails, phone numbers, and payment customer IDs leak externally with a single curl.

Vulnerable SQL

-- 危険:RLS を有効化していないテーブル
create table public.profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  full_name text,
  phone text,
  stripe_customer_id text
);
-- ↑ この migration には enable row level security が無い。
--   結果、次の1行で全ユーザーの個人情報が返る:
--   curl "https://<project>.supabase.co/rest/v1/profiles?select=*" \
--        -H "apikey: <anon-key>"

A migration of just create table is, as SQL, perfectly correct. The types, NOT NULL, and foreign key are all appropriate. It's a typical example showing that "working" and "being safe" are completely separate problems.

Fixed SQL

It's important that the fix is two stages. The order is enable (make it fail-secure) → explicitly allow only the necessary access.

-- ステップ1:RLS を有効化する(これ自体が fail-secure =デフォルト全拒否)
alter table public.profiles enable row level security;

-- ステップ2:必要なアクセスだけを明示的に許可する。
--   有効化しただけでポリシーが0個なら「誰も読めない」が初期状態。
--   そこから「自分の行だけ読める」を足す。
create policy "users read own profile"
on public.profiles
for select
to authenticated                         -- anon ではなくログイン済みだけに限定
using ( (select auth.uid()) = user_id );

There are three points to grasp.

  1. Enabling RLS is fail-secure. Enable RLS with no policy at all, and the default is "deny all" (PostgreSQL: Row Security Policies). Do "just enable for now" and you at least stop defenseless full exposure. The first thing a detection tool should pick up is this "table where enable doesn't exist."
  2. Wrap auth.uid() with (select auth.uid()). It's the way Supabase officially recommends — it doesn't re-evaluate the function per row but caches it in the initial plan, so performance changes by orders of magnitude on a large table (Supabase: Row Level Security). You avoid the trap of "works correctly but slow."
  3. The table owner bypasses RLS. In PostgreSQL, the table owner (in Supabase, mostly postgres) is by default exempt from RLS. Unless you attach ALTER TABLE ... FORCE ROW LEVEL SECURITY, access via the owner doesn't undergo row control (PostgreSQL: Row Security Policies). This directly connects to the pitfall of the SECURITY DEFINER function (pattern ⑤) described later.

3. Dangerous pattern ②: USING (true) / an unconditional policy

You enabled RLS and wrote a policy too. But that condition is "always true" — this is pattern ②.

What happens

using (true) means "make all rows visible." In the reassurance of having enabled RLS, you effectively revert "deny all" to "allow all." Look only at the log of alter table ... enable row level security and it looks like you're protecting it, while the inside passes through — the case where detection has the most value.

Vulnerable SQL

alter table public.documents enable row level security;

-- 危険:条件が true =全行を無条件に許可(RLSを有効化した意味が消える)
create policy "anyone can read documents"
on public.documents
for select
using ( true );

What's also easily overlooked is omitting the TO clause. A policy without TO applies to PUBLIC, that is, all roles including anon (PostgreSQL: CREATE POLICY). The above policy doesn't even have to authenticated, so even a not-logged-in anon can read all documents. The combo of using (true) and omitting TO is synonymous with full exposure of a sensitive table.

Fixed SQL

-- 所有者の行だけを、ログイン済みユーザーに対して許可する
create policy "owners read their documents"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );

Here, let me draw an honest line. using (true) is not necessarily a bug. For a public table of blog articles, or a table like master data where "everyone really may read it," to anon using (true) is correct design. So the correct behavior for a detection tool is to emit using (true) not as "instantly vulnerable" but as a warning that prompts a human to confirm: "an unconditional allow. Is this really the publication intent?" What the machine can judge goes only as far as the syntactic fact that "it's unconditional," and "is this data OK to publish" is domain knowledge. I'll emphasize this dividing line again later in this article.


4. Dangerous pattern ③: missing WITH CHECK — creating "can't read but can write"

This is the most misunderstood point in RLS. Reading is perfectly locked, but writing is defenseless — this state normally occurs. The cause is confusing the roles of USING and WITH CHECK.

USING and WITH CHECK are separate axes

A PostgreSQL policy has two kinds of expressions (PostgreSQL: CREATE POLICY).

ExpressionWhat it inspectsCommands it affects
USINGExisting rows. Which rows are visible / can be the target of update/deleteSELECT / UPDATE / DELETE
WITH CHECKThe value of a new row. Whether the row to write at INSERT/UPDATE is allowedINSERT / UPDATE

What's decisively important is that INSERT has no USING. A new row has no "existing row," so the only thing that can constrain INSERT is WITH CHECK. Therefore, an INSERT policy where you forgot WITH CHECK / set it to true passes through a write with any value.

What happens

There are two concrete harms.

  1. Impersonation insert: a user INSERTs with user_id set to someone else's ID, planting data in someone else's name / contaminating another tenant.
  2. Ownership rewrite: with UPDATE, rewriting your own row's owner_id to someone else, or escalating role to admin.

Vulnerable SQL

alter table public.documents enable row level security;

-- 読みは「自分の行だけ」——ここは完璧に見える
create policy "read own"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );

-- 危険①:INSERT の検査が true =任意の owner_id で行を作れる(なりすまし挿入)
create policy "insert any"
on public.documents
for insert
to authenticated
with check ( true );

-- 危険②:UPDATE は対象行を自分に絞っているが、新しい行の値を検査しない
--   → 自分の行の owner_id を他人に書き換えられる(所有権の移譲・剥奪)
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( true );

The terror of this configuration is that because the SELECT policy is perfect, you get the illusion that "RLS is in effect." In a read test, others' data isn't visible, so it passes review. The hole is open on the write side.

Fixed SQL

In write-system policies, always require in WITH CHECK that the owner of the new row is yourself.

-- INSERT:作る行の owner_id は必ず自分
create policy "insert own"
on public.documents
for insert
to authenticated
with check ( (select auth.uid()) = owner_id );

-- UPDATE:対象も新しい行も自分に限定(owner_id の書き換えを封じる)
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );

A detection-side note: omitting WITH CHECK is not "always a bug"

To be accurate, if you omit WITH CHECK in a FOR ALL or FOR UPDATE policy, PostgreSQL reuses the USING expression for WITH CHECK too (PostgreSQL: CREATE POLICY). That is, the following is safe.

-- USING がスコープされ、WITH CHECK 省略 → USING が流用されるので書き込みも守られる
create policy "manage own"
on public.documents
for all
to authenticated
using ( (select auth.uid()) = owner_id );

Therefore, what a detection tool should really pick up is not "omission" but (a) FOR INSERT with no WITH CHECK / true, (b) explicitly stating WITH CHECK ( true ) for any command, and (c) the USING side being using (true) in the first place, so reuse is meaningless — these three. Mechanically alarm "omission = instant bug" and false positives (noise) increase, and the tool stops being trusted. The quality of RLS verification is determined by how accurately you can model these kinds of behavioral differences.


5. Dangerous pattern ④: over-GRANT/policy to the anon role

In Supabase, two database roles are used depending on the JWT: the not-logged-in anon and the logged-in authenticated. PostgREST runs queries as these roles, and RLS is evaluated against that role.

What happens

Think of anon as "the entire internet." Hand anon access to a sensitive table and it leaks without even needing authentication. There are two routes to over-granting.

  1. Target anon in a policy (to anon, or including it in PUBLIC by omitting TO)
  2. GRANT table permissions to anon (grant select on ... to anon)

Vulnerable SQL

alter table public.orders enable row level security;

-- 危険①:anon に対して全件可視
create policy "public read orders"
on public.orders
for select
to anon
using ( true );

-- 危険②:TO 省略 → PUBLIC(anon を含む)に適用される
--   作者は「ログイン済み向け」のつもりでも、anon にも開いてしまう
create policy "read orders"
on public.orders
for select
using ( (select auth.uid()) = customer_id );
-- ↑ anon は auth.uid() が NULL なので結果的に0件だが、
--   ポリシーの対象に anon が含まれること自体が設計意図のズレで、
--   using を true 系に書き換えた瞬間に全公開へ反転する地雷になる

In addition, also look at the permission grant on the table itself.

-- 危険:anon に直接 SELECT 権限を与える(RLS が無効だと即・全公開)
grant select on public.orders to anon;

Fixed SQL

In principle, limit sensitive tables to an authenticated starting point, and narrow exposure to anon to only "data that's really OK to publish."

-- ポリシーは authenticated を明示し、所有権で絞る
create policy "customers read own orders"
on public.orders
for select
to authenticated
using ( (select auth.uid()) = customer_id );

-- anon に渡した過剰な権限は剥がす
revoke select on public.orders from anon;

From the detection standpoint, surface "policies for to anon / omitting TO" and "GRANTs to anon" against sensitive tables, and warn about ones whose publication intent isn't explicit. The anon key itself is "a key OK to publish on the premise of being used together with RLS" (Supabase: API keys), but that's only if RLS is correctly in effect. The safe placement of the key itself is detailed in anon/service_role-key exposure.


6. Dangerous pattern ⑤: SECURITY DEFINER functions without a fixed search_path

The last is a pattern that opens a hole from "outside" RLS. It lurks in PostgreSQL functions published as RPC (remote functions).

What happens

A function with SECURITY DEFINER attached runs with the function owner's permissions, not the caller's (PostgreSQL: CREATE FUNCTION). In Supabase, the function owner is mostly postgres — that is, the table owner, the role that bypasses RLS (the 3rd point of Section 2). Therefore, queries inside a SECURITY DEFINER function completely ignore RLS. Publish this to anon/authenticated via PostgREST without an ownership check inside the function, and however much RLS you stretch, it's passed through.

Further, if you don't fix search_path, it's another level of danger. The PostgreSQL official docs clearly warn that "a SECURITY DEFINER function can be hijacked if an untrusted user can create objects in a schema searched earlier, so fix search_path to a safe value" (PostgreSQL: CREATE FUNCTION). An unqualified name (a reference without a schema, like profiles) gets resolved to a different object the attacker prepared and runs with owner permissions — a breeding ground for privilege escalation.

Vulnerable SQL

-- 危険:所有者権限で動くのに、search_path 未固定・所有権チェック無し・anon に開放
create or replace function public.delete_account(target uuid)
returns void
language plpgsql
security definer                       -- postgres 権限で実行=RLSバイパス
as $$
begin
  delete from profiles                 -- 非修飾名(search_path 次第で別物に解決されうる)
  where id = target;                   -- 呼び出し元が誰かを一切問わない
end;
$$;

-- PostgREST 経由で誰でも呼べてしまう
grant execute on function public.delete_account(uuid) to anon, authenticated;
-- 結果:任意の target を渡せば、他人のアカウントを削除できる

This function, even against a profiles with RLS perfectly stretched, can delete all rows because it runs with owner permissions. RLS is irrelevant.

Fixed SQL

If you use SECURITY DEFINER, thoroughly apply 4 points: (1) fix search_path, (2) fully qualify objects, (3) enforce the caller's identity inside the function, and (4) don't hand it to anon.

create or replace function public.delete_my_account()
returns void
language plpgsql
security definer
set search_path = ''                   -- search_path を固定し、非修飾名の乗っ取りを封じる
as $$
begin
  delete from public.profiles          -- 完全修飾名で参照する
  where id = (select auth.uid());      -- 引数を信じず、呼び出し元本人に限定
end;
$$;

-- 公開範囲を最小化:PUBLIC から剥がし、ログイン済みだけに付与
revoke all on function public.delete_my_account() from public;
grant execute on function public.delete_my_account() to authenticated;

The detection standpoint is simple. List all functions that have security definer but no set search_path, and additionally warn about grant execute to anon. The former can be reliably picked up by syntax alone (the presence of search_path is machine-judgable). The latter, "whether identity is enforced inside the function," requires reading the function body's logic, and this is half the human domain.


7. Why they're mass-produced in AI-generated code and rushed development

The 5 patterns up to here can be avoided if you know them. Why are they mass-produced nonetheless? The reason is structural.

RLS misconfigurations absolutely don't surface in a demo. As long as you're touching it with one account of your own, the screen looks the same whether using (true) or with an ownership check. Even without WITH CHECK, updating your own row works. The "worked = correct" trap is especially deep in authorization. The bug becomes visible only "when someone else's account exists and you cross that boundary," and that mostly doesn't happen during development.

AI code generation amplifies this tendency. AI writes code that achieves the prompt's "what you want to do (= working in a demo)" by the shortest path. "Don't show others' data" and "can't impersonate others" are outside the happy path unless explicitly required, and they're either not output, or tend to be a "just-passes" form like using (true).

This isn't abstraction but is recorded as a real incident. CVE-2025-48757, registered in 2025, is the typical case. According to the NVD description, due to insufficient Row-Level Security policies in the AI app-generation platform Lovable (through 2025-04-15), a remote unauthenticated attacker could read and write arbitrary DB tables of generated sites. The classification is CWE-863 (Incorrect Authorization), and the CVSS base score is 9.3 CRITICAL. It's evidence that this article's pattern ① (RLS not enabled) and ④ (exposure to anon) actually happened at scale.

And the typical harm this misconfiguration brings is an authorization failure — API1:2023 Broken Object Level Authorization (BOLA), which OWASP ranks #1 in API risks. RLS is a primary line of defense to prevent this BOLA "at the DB layer," and its configuration crumbling directly connects to the most frequent API risk.

In short, an RLS misconfiguration is not "a rare, advanced bug" but a "normal accident" structurally born in modern development that prioritizes speed. That's exactly why, in addition to human review, there's value in building mechanical detection into the pipeline.


8. Automating detection: the idea of static verification of migrations

What the 5 patterns have in common is that they all leave a trace in the DDL of supabase/migrations/**.sql. You can judge by reading the SQL without actually attacking. This is "static verification of migrations."

A simple grep is insufficient

What to be careful of is that a grep on a single file misjudges. Enabling RLS can be done in a subsequent migration separate from table creation. Judge enable row level security as "dangerous because it's not in some file," and you false-positive cases enabled in another file. Correctly, you need to fold all migrations chronologically and reconstruct each table's "final state."

A conceptual model

Let me show the idea in pseudo-code. Fold migrations, build a state model per table, and match against the dangerous patterns.

// migrations を時系列で畳み込んで得る「最終状態」のモデル
type PolicyModel = {
  table: string;
  command: "select" | "insert" | "update" | "delete" | "all";
  roles: string[];          // 空配列 = TO 省略 = PUBLIC(anon を含む)
  using: string | null;     // 既存行に対する条件
  withCheck: string | null; // 新しい行に対する条件
};

type TableModel = {
  schema: string;
  name: string;
  rlsEnabled: boolean;      // ALTER TABLE ... ENABLE ROW LEVEL SECURITY を畳み込んだ結果
  policies: PolicyModel[];
};

const isTrue = (e: string | null) => e !== null && /^\s*true\s*$/i.test(e);
const facesAnon = (p: PolicyModel) => p.roles.length === 0 || p.roles.includes("anon");

function findings(t: TableModel): string[] {
  const out: string[] = [];

  // ① RLS未有効化(PostgREST に露出する public スキーマ)
  if (t.schema === "public" && !t.rlsEnabled)
    out.push("RLS未有効化:anon キーで全件読める可能性");

  for (const p of t.policies) {
    // ② USING(true) かつ anon/PUBLIC 向け → 無条件公開(要・公開意図の確認)
    if (isTrue(p.using) && facesAnon(p))
      out.push(`USING(true):${p.command} を無条件許可(本当に公開?)`);

    // ③ INSERT は USING を持たない=WITH CHECK だけが頼り
    if (p.command === "insert" && (p.withCheck === null || isTrue(p.withCheck)))
      out.push("INSERT が無検査:任意の owner_id で行を作れる");
    // 明示的な WITH CHECK(true) は、どのコマンドでも新しい行を検査しない
    if (isTrue(p.withCheck))
      out.push(`WITH CHECK(true):${p.command} で所有者の書き換え/なりすまし可能`);

    // ④ anon / PUBLIC 向けポリシー(公開意図が明示されているか要確認)
    if (facesAnon(p) && !isTrue(p.using))
      out.push(`anon/PUBLIC 対象:${p.command} の露出範囲を確認`);
  }
  return out;
}

This is just the skeleton. In actual detection, consider the behavioral difference described in Section 4 — "FOR ALL/FOR UPDATE reuses USING when WITH CHECK is omitted" — to reduce false positives, and also analyze the presence of search_path in SECURITY DEFINER functions and GRANTs to anon.

Running it with OSS

The OSS that can run this detection with no configuration is Aegis (MIT license). It analyzes supabase/migrations and matches against the above catalog (①–⑤).

# インストール不要・設定不要。プロジェクト直下で実行する
npx @aegiskit/cli scan

I won't overclaim. This is "heuristic static analysis against DDL," nothing more, nothing less. I'll draw the line of the honest scope in Section 11 later. RLS design itself — for example production multi-tenancy patterns, testing with pgTAP to prevent policy regressions, and on-the-ground verification of cross-tenant crossing — are handled in separate articles.


9. Even if you can "detect," "correct design" is a separate problem

This is the most important section of this article. Up to the previous section, I said "dangerous patterns can be machine-detected." So, if detections become 0, is the RLS "correct"? No.

What a detection tool sees is the "shape (syntax, structure)" of policies and functions. Not the "meaning" of your business rules and data model. Concretely, there are things the tool fundamentally can't know.

  • The ownership model: is the column representing this table's "owner" owner_id, user_id, or created_by? Is the column to match against auth.uid() really that column?
  • Tenant boundaries: in multi-tenancy, are you isolating by user_id when rows should be isolated by organization_id? Are rows all members of an organization should see locked to only the creator (or vice versa)?
  • Business rules: is state-dependent visibility like "drafts are for the author only, published is for everyone" correctly reflected in the policy?
  • Role design: what may admin cross? Is that "crossing" an intended permission, or a bug?

It normally happens that a syntactically perfect policy (select auth.uid()) = user_id is domain-wise wrong. For example, lock data that should originally be shared at the organization level to the individual level, and detection is 0 but it doesn't meet the requirements (or a hole opens by being "worked around" with using (true) elsewhere).

So the design goal of correct production RLS is "detections become 0" — this is a necessary condition, not a sufficient one. 0 detections is proof of "you didn't step on a common trap," not proof that "authorization is correct." The latter can be said only once humans design, review, and "confirm" the ownership model and tenant boundaries with cross-tenant-crossing verification and pgTAP regression tests.

In a B2B SaaS where tenant-boundary design is the business's lifeline — for example a multi-tenant platform like the lumber-distribution DX case I was involved in — the very drawing of the line of "who owns what of which tenant" was the main body of authorization design. A tool is a tool to verify that line fast and without gaps, and can't proxy the drawing of the line itself.


10. A pre-production RLS checklist

Whether outsourced or AI-generated, before going to production, confirm at least these. All can be judged by reading supabase/migrations/**.sql.

ViewpointWhat to confirmDanger sign
RLS enablementDo all public tables have enable row level securityOnly create table, no enable
Default denyDoes an enabled table have the necessary policies in placeEnabled but with 0 policies, breaking functionality, and "worked around" with using(true)
Unconditional allowIs the publication intent of using (true) / with check (true) clearAn unconditional policy on a sensitive table
WITH CHECKDo INSERT/UPDATE inspect the new row's ownerfor insert with no with check / true
TO clauseDoes the policy make the target role explicit with to authenticated etc.Omitting TO opens it up to anon
anon permissionsNo grant ... to anon on sensitive tablesA personal-info table GRANTed to anon
SECURITY DEFINERIs set search_path fixed and identity enforced inside the functionsearch_path not fixed / execute granted to anon
Verification automationAre scans / regression tests built into CINo basis other than "worked on my machine"

The two most effective questions from the buyer's viewpoint are: "If I hit it not logged in (anon), which tables are visible?" "How many security definer functions are there, and does each one fix search_path?" — if you can't answer clearly and immediately, there's a high chance RLS verification isn't built into the design.


11. The range you do yourself vs. the range you leave to an audit (honestly)

Finally, let me show the realistic dividing line. What to do yourself, and from where to leave to a specialist.

The range you should do yourself (= OSS covers it enough):

  • Machine detection of patterns ①–⑤. Put npx @aegiskit/cli scan into CI and run it automatically on every migration. This is free and can be done from today.
  • The syntax-level confirmation of the checklist. The presence of enable, the presence of with check, and the presence of search_path can be picked up by a human reading the SQL too.
  • Basic regression testing with pgTAP. Write one for "others' rows aren't visible / writable."

The range you should leave to an audit (= human design judgment is needed):

  • A design review of the ownership model and tenant boundaries. Whether the column matched against auth.uid() is correct, whether organization-level sharing matches the requirements — this is judgment after understanding your business.
  • The "beyond" of 0 detections. Even if the detection tool is clean, whether there's a domain-wise hole is a separate problem. Threat modeling and on-the-ground verification of actually impersonating another tenant and hitting it are needed.
  • An inventory of the existing app's whole authorization. A cross-cutting review including not just RLS but the service_role route, RPC, Storage, and external join destinations.

Along this dividing line, the OSS Aegis handles "detection," and the security audit handles "actually plugging it in design and implementation." The former is free, and the latter is provided as a spot diagnosis or a standard audit. I'll never, ever say "safe because I put in a tool" — that complacency is exactly what produces the worst outcome. A tool complements human judgment, not replaces it. Mechanically crush the most frequent traps so humans can concentrate on the truly hard design judgments. That's the correct way to use it.


Frequently asked questions (FAQ)

Q. If I enable RLS on all tables, can I prevent misconfigurations? A. You can prevent pattern ①, but not ②–⑤. Passing through with using (true), defenseless writes for lack of WITH CHECK, jumping over RLS with a SECURITY DEFINER function — enabling is the starting point, not the goal.

Q. Is using (true) always a bug? A. No. For data that's really OK to publish (public articles, master data, etc.), it's correct design. So detection should be treated as a confirmation warning of "an unconditional allow. Is this the publication intent?", and the machine declaring "instant bug" is wrong. Whether it's the publication intent is domain knowledge.

Q. If I write only USING, are writes protected too? A. It depends on the command. FOR ALL/FOR UPDATE reuse USING when WITH CHECK is omitted, so they're protected, but FOR INSERT has no USING, so without WITH CHECK it's uninspected. Think of reads and writes as separate axes.

Q. Should I not use SECURITY DEFINER functions? A. There are legitimate uses (aggregation or admin processing that crosses RLS, etc.). The iron rule is to fix set search_path, fully qualify objects, enforce identity inside the function, and not hand execute to anon (PostgreSQL: CREATE FUNCTION). Don't publish a function that can't keep these.

Q. If detections are 0, can I say RLS is correct? A. You can't. 0 detections is proof of "you didn't step on a common trap," not proof that "authorization is correct." Whether the ownership model and tenant boundaries match the requirements needs to be confirmed by human design judgment and on-the-ground verification. As in Section 9, 0 is a necessary condition, not a sufficient one.


Summary: the dangerous patterns are finite, detection can be mechanized. Humans do the design

Let me organize the key points.

  • Supabase auto-turns public tables into APIs. RLS not enabled means full exposure with "just creating a table," and this is what actually happened in CVE-2025-48757.
  • RLS misconfigurations aren't infinite; they converge to 5 finite patterns — ① not enabled ② unconditional allows like using (true) ③ missing WITH CHECK ④ over-granting to anonSECURITY DEFINER without a fixed search_path.
  • USING (existing rows) and WITH CHECK (the value of a new row) are separate axes. A state of perfect reads but defenseless writes normally happens.
  • These can be mechanically surfaced by statically verifying supabase/migrations/**.sql folded chronologically. The OSS npx @aegiskit/cli scan (MIT) does this.
  • But "detection" and "correct design" are different things. The design of the ownership model and tenant boundaries is human judgment, and 0 detections is "you didn't step on a trap," not proof that "authorization is correct."

Building fast with AI itself is correct. The problem is that there's no mechanism to verify, without gaps, the authorization of what you built fast. Machine detection of the dangerous patterns can start from today with the free OSS Aegis (npx @aegiskit/cli scan), and what comes after — a design review delving into the ownership model and tenant boundaries, and an RLS inventory of an existing Supabase app — I help with via the security audit touched on in the previous section. Feel free to consult me.


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