Supabase RLS · Reference
The 18 ways Supabase RLS breaks
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.
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;
pg_tables.rowsecurityTautology 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()));
pg_policies.qual and with_checkAnon 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
auth.uid() or a membership queryAny 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()))
auth.uid()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()))
pg_policies.with_checkPolicy 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()
auth.uid()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
pg_proc.prosecdef and checking execute grantsAnon 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;
has_function_privilege('anon', oid, 'EXECUTE')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 = '';
pg_proc.proconfig for a search_path settingView 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);
pg_class.reloptions for security_invokerStorage 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';
storage.buckets.publicAnon-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;
pg_publication_tables with anon-readable findingsRole 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;
pg_roles.rolbypassrls and rolsuperTable 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;
pg_class.relowner and relforcerowsecurityAnon 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
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
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
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
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.
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.
npm · GitHub · MCP server
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 →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.