Home / RLS / Failure taxonomy

Supabase RLS · Reference

The 18 ways Supabase RLS breaks

Postgres 15+ · Supabase · Airlock✓ every example tested against a real database
What this is

A public, reproducible taxonomy of every Row Level Security failure mode Airlock checks for. Each entry has a code, a severity, a minimal SQL example that opens the gap, and how Airlock detects it. Use it to audit your own app, or run npx airlock-rls to check automatically.

6
Fail (leak confirmed)
12
Warn (review needed)
18
Total checks
Static analysis (catalog queries) Dynamic probe (real API request) Both layers
Static findings — policy and configuration analysis
T1 · failrls_disabled

RLS turned off

The table has Row Level Security disabled entirely. No policy applies — every row is exposed to anon and authenticated through the API. This is the most common leak in new Supabase projects.

-- reproduce: create a table without enabling RLS
create table secrets (id int, value text);
-- anon can: SELECT * FROM secrets → all rows
-- fix:
alter table secrets enable row level security;
Detected by reading pg_tables.rowsecurity
T2 · failpermissive_true

Tautology policy — USING(true) or WITH CHECK(true)

RLS is on, but a permissive policy evaluates true for every caller. The effect is identical to RLS being off: the table is publicly readable or writable. Usually copy-pasted from a tutorial.

-- reproduce:
create policy "allow all" on notes for select using (true);
-- anon can: SELECT * FROM notes → all rows
-- fix: scope to the caller
drop policy "allow all" on notes;
create policy "owner reads" on notes for select
  using (user_id = (select auth.uid()));
Detected by parsing pg_policies.qual and with_check
T3 · failanon_unscoped

Anon reads without scoping to a user

A permissive SELECT/ALL policy applies to the anon role with a USING clause that doesn't reference auth.uid() or a membership subquery. Any visitor with the public anon key reads every row.

-- reproduce:
create policy "anon reads" on profiles for select to anon
  using (is_active = true);  -- not scoped to a user
-- anon can: read every active profile
-- fix: restrict to authenticated, or scope
Detected by checking if USING references auth.uid() or a membership query
T4 · failauthenticated_unscoped

Any authenticated user reads all rows (cross-tenant IDOR)

A permissive policy applies to authenticated with a USING clause that doesn't scope to the current user. Any logged-in user reads every other user's rows — a classic insecure direct object reference.

-- reproduce:
create policy "users read" on orders for select to authenticated
  using (true);
-- user B can: SELECT * FROM orders → sees user A's orders
-- fix:
using (user_id = (select auth.uid()))
Detected by checking if USING scopes to auth.uid()
T5 · warnwrite_unchecked

INSERT/UPDATE with no WITH CHECK guard

A write policy (INSERT, UPDATE, or ALL) has no WITH CHECK clause and the USING clause doesn't scope to the caller. A user can insert or update rows attributed to another user — a row-forging vector.

-- reproduce:
create policy "insert any" on tasks
  for insert to authenticated
  with check (true);
-- user B can: INSERT INTO tasks (user_id, title)
--   VALUES (user_A_id, 'forged') → succeeds
-- fix:
with check (user_id = (select auth.uid()))
Detected by checking pg_policies.with_check
T6 · warnhelper_scoped

Policy scoped through a helper function

The USING clause delegates scoping to a function call (e.g. is_member(org_id)). Static analysis can't see inside the function body — the policy might be correctly scoped or completely open. Manual review required.

-- reproduce:
create policy "member reads" on projects for select
  using (is_member(org_id));  -- what does is_member do?
-- could be fine, could be USING(true) in disguise
-- fix: verify the function body restricts to auth.uid()
Detected by identifying function calls in USING that are not auth.uid()
T7 · warnsecurity_definer

SECURITY DEFINER function bypasses RLS

A function marked SECURITY DEFINER runs as its owner (usually postgres), not the caller. If it reads or writes a table, RLS policies don't apply. Fine for RLS helper functions — dangerous for data-access functions.

-- reproduce:
create function get_all_orders()
  returns setof orders
  language sql security definer
  as $$ select * from orders $$;
-- any authenticated user: SELECT * FROM get_all_orders()
--   → returns ALL orders, regardless of RLS
Detected by reading pg_proc.prosecdef and checking execute grants
T8 · warnanon_executes_definer

Anon can EXECUTE a SECURITY DEFINER function

A SECURITY DEFINER function is callable by the anon role. An unauthenticated visitor can call a function that runs as its owner, bypassing every RLS policy. This is the highest-risk variant of T7.

-- reproduce:
grant execute on function get_all_orders() to anon;
-- now an unauthenticated visitor can call it
-- fix:
revoke execute on function get_all_orders() from anon;
Detected via has_function_privilege('anon', oid, 'EXECUTE')
T9 · warndefiner_search_path_mutable

SECURITY DEFINER with no pinned search_path

A SECURITY DEFINER function resolves unqualified table names against the caller's search_path. An attacker can prepend a schema with a trojan table, and the function — running as owner — reads from the attacker's table instead. A privilege-escalation vector.

-- reproduce: function without SET search_path
create function count_orders()
  returns bigint
  language sql security definer
  as $$ select count(*) from orders $$;
-- attacker sets search_path = attacker_schema, public
-- fix:
alter function count_orders() set search_path = '';
Detected by reading pg_proc.proconfig for a search_path setting
T10 · warnview_bypasses_rls

View without security_invoker — owner-rights bypass

A view runs as its owner by default (Postgres < 15 behavior). If the view reads a table with RLS, the policies don't apply because the owner bypasses them. Set security_invoker = true (Postgres 15+) so the view runs as the caller.

-- reproduce:
create view all_profiles as select * from profiles;
-- anon can: SELECT * FROM all_profiles → all rows
--   (the view's owner bypasses profiles' RLS)
-- fix:
alter view all_profiles set (security_invoker = true);
Detected by reading pg_class.reloptions for security_invoker
T11 · warnpublic_bucket

Storage bucket is public

A Supabase Storage bucket is marked public = true. Anyone with the file URL can download its contents — no authentication, no RLS. Fine for public assets; dangerous for user documents.

-- reproduce:
update storage.buckets set public = true
  where id = 'documents';
-- anyone can: GET /storage/v1/object/public/documents/...
-- fix:
update storage.buckets set public = false
  where id = 'documents';
Detected by reading storage.buckets.public
T12 · warnrealtime_exposure

Anon-readable table streams via Realtime

A table that is already anon-readable (T1/T2/T3) is also published to Supabase Realtime. Every INSERT, UPDATE, and DELETE on that table is broadcast to anonymous WebSocket subscribers in real time.

-- reproduce: table with RLS off + Realtime enabled
alter publication supabase_realtime add table messages;
-- anon subscribers see every row change in real time
-- fix: remove from Realtime or fix the RLS leak first
alter publication supabase_realtime drop table messages;
Detected by cross-referencing pg_publication_tables with anon-readable findings
T13 · warnbypassrls_role

Role with BYPASSRLS or SUPERUSER

A database role has BYPASSRLS or is a superuser. RLS never applies to it — every policy on every table is skipped. On stock Supabase, managed roles like postgres and supabase_admin have this legitimately. The danger is when a client-reachable role (anon, authenticated) has it, or when a custom role bypasses without review.

-- reproduce:
alter role authenticated bypassrls;
-- now every authenticated user bypasses every policy
-- fix:
alter role authenticated nobypassrls;
Detected by reading pg_roles.rolbypassrls and rolsuper
T14 · warnowner_bypasses_rls

Table owned by a client-reachable role without FORCE RLS

A table's owner is a role the API caller can reach (e.g. authenticated). The table owner always bypasses its own RLS — unless FORCE ROW LEVEL SECURITY is set. On stock Supabase the owner is postgres (safe), but a custom setup may differ.

-- reproduce:
alter table invoices owner to authenticated;
-- authenticated bypasses all RLS on invoices
-- fix:
alter table invoices owner to postgres;
-- or:
alter table invoices force row level security;
Detected by reading pg_class.relowner and relforcerowsecurity
Dynamic findings — real API probes (DAST)
D1 · failanon_read_leak

Anon key reads real rows over the REST API

A GET request to the Supabase REST API with only the anon key returns actual rows from a table. The leak isn't inferred from policy text — it's proven by the data that came back. If the columns include personal data (email, phone, SSN), the finding says so.

-- the probe (not SQL — this is an HTTP request):
GET /rest/v1/profiles?select=*&limit=1
apikey: <anon_key>
-- if HTTP 200 with rows → PROVEN LEAK
-- if HTTP 200 with [] or 401/403 → safe
Detected by sending a real GET through the project's public API
D2 · failanon_write_leak

Anon key inserts a row over the REST API

A POST request to the REST API with only the anon key successfully creates a row. An unauthenticated visitor can write to your database. If the insert passes RLS but fails on a column constraint, the finding still reports it — the RLS gate was open.

-- the probe:
POST /rest/v1/profiles
apikey: <anon_key>
body: {}
-- if HTTP 201 → PROVEN LEAK (row created)
-- if 400/409 without "row-level security" → RLS passed
Detected by sending a real POST through the project's public API
D3 · failcross_tenant_read_leak

User B reads user A's rows

Two temporary test users are created. User A inserts a row. User B — a different tenant — reads the same table and gets User A's row back. The cross-tenant isolation is broken: this is the leak that lets one customer see another's data.

-- the probe (simplified):
-- 1. Create user_a, create user_b
-- 2. As user_a: INSERT INTO tasks (user_id, title)
--    VALUES (user_a_id, 'secret task')
-- 3. As user_b: SELECT * FROM tasks
--    WHERE user_id = user_a_id
-- if rows come back → CROSS-TENANT LEAK
Detected by API probe with two test users + SQL simulation
D4 · failcross_tenant_write_leak

User B writes to user A's rows

User B successfully updates or deletes a row that belongs to User A. The USING clause might scope reads correctly, but a missing WITH CHECK on UPDATE lets the write through. This is how one customer modifies another's data.

-- the probe (simplified):
-- 1. As user_a: INSERT INTO tasks (...) → row_id
-- 2. As user_b: PATCH /rest/v1/tasks?id=eq.row_id
--    body: {"title": "changed-by-b"}
-- if HTTP 200 and row changed → CROSS-TENANT LEAK
Detected by API probe with two test users + SQL simulation

Coverage summary

Airlock checks all 18 failure modes. 14 are caught by static analysis alone (reading the database catalog in a single read-only transaction). 2 are caught by dynamic probes (real HTTP requests with the anon key). 2 require cross-tenant probes with temporary test users.

Static analysis is fast and safe — it never writes to your database. Dynamic probes confirm what static analysis suspects. A finding that both layers agree on is stronger than either one alone.

If you find a failure mode we don't cover, open an issue. We'll add it.

◆ FREE · MIT · npm + GitHub Action

Check your app against all 18

airlock-rls runs the static checks (T1–T14) against your database in CI. One command, exit code 1 on any fail. The DAST probes (D1–D4) run when you provide a project URL and anon key.

npx airlock-rls --db-url $SUPABASE_DB_URL→

npm · GitHub · MCP server

AIRLOCK MONITOR

Continuous checks, not just one-off

The hosted Airlock Monitor runs these checks against your live database every hour — not just once in CI. Dashboard, alerts, fix suggestions, and a history of every run.

Learn more about the Monitor →
FREE PDF

Grab the Supabase RLS cheat sheet

The golden rules, the footguns that leak in prod, correct policy snippets, and the isolation test — on one page.