HomeGuidesSupabase ON DELETE CASCADE skips RLS: lock it down

Guide

Supabase ON DELETE CASCADE skips RLS: lock it down

A cascade from one allowed DELETE wipes child rows that other users own, and RLS on the child table does not stop it. Tested fixes for Supabase.

15 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

A user taps Delete project. Your RLS policy says only the owner can delete a project, and she is the owner, so the delete goes through. So do the 200 tasks and 200 comments her teammates added to it. Their RLS policies said only they could delete those rows. Postgres never asked.

That is ON DELETE CASCADE doing exactly what it promises. The PostgreSQL row security docs say referential integrity checks, such as foreign key references, "always bypass row security to ensure that data integrity is maintained." On Supabase, where the client can send DELETE straight to your tables, that turns one allowed delete into a delete of everything below it.

This guide shows what we saw when we tested it, how to find every cascade in your project, and five safer patterns with the SQL we ran. Every SQL block was run on PostgreSQL 17.11 behind PostgREST 16.5 (the API layer Supabase uses) with @supabase/postgrest-js 2.109.0 and Supabase style anon, authenticated, and service_role roles.

Checked against the PostgreSQL and Supabase docs linked below on October 27, 2026. VibeLocked is independent and not affiliated with Supabase.

The short version

  • RLS guards the row the user deletes, not the rows a cascade removes. In our test the cascade ignored child table policies, the missing DELETE grant, and FORCE ROW LEVEL SECURITY.
  • Find your cascades with a pg_constraint query where confdeltype = 'c', and check whether the parent is deletable from the client.
  • For shared parents (teams, projects, workspaces, boards), use ON DELETE RESTRICT or the default NO ACTION, or stop client deletes of the parent.
  • Delete through a server path with checks: a SECURITY DEFINER function or an Edge Function that decides what happens to other users' rows.
  • Soft delete with a deleted_at column when users need a delete button but data must survive.
  • Revoke DELETE from anon everywhere and from authenticated on parents that should not be deleted from the browser.

What we tested

Three users. Alice owns project 1. Bob and Carol are collaborators who each added 100 tasks to it, and every task has one comment. Bob also has his own project 2 with 10 tasks. The schema is what AI tools usually write for a project app:

create table public.projects (
  id bigint primary key,
  owner_id uuid not null references auth.users (id),
  name text not null
);

create table public.tasks (
  id bigint primary key,
  project_id bigint references public.projects (id) on delete cascade,
  owner_id uuid not null references auth.users (id),
  title text not null
);

create table public.comments (
  id bigint primary key,
  task_id bigint not null references public.tasks (id) on delete cascade,
  owner_id uuid not null references auth.users (id),
  body text not null
);

RLS is on for all three tables. Signed in users can read every project, but only the owner can delete one. Tasks and comments are private: each user can read and delete only their own.

create policy "owner deletes project" on public.projects
  for delete to authenticated using ((select auth.uid()) = owner_id);

create policy "read own tasks" on public.tasks
  for select to authenticated using ((select auth.uid()) = owner_id);

create policy "delete own tasks" on public.tasks
  for delete to authenticated using ((select auth.uid()) = owner_id);

Comments have the same two policies as tasks.

What the cascade did

Through the API, signed in as each user:

Request Result
Count tasks 100, only her own
Delete tasks in project 1 that she does not own 204, 0 rows, nothing deleted
Bob deletes Alice's project 204, 0 rows, nothing deleted
Alice deletes her own project 200, 1 row returned, 300 tasks and 300 comments gone

The delete that wiped the data was a normal client call:

await supabase.from('projects').delete().eq('id', 1).select()

The response showed one project row and nothing else. There was no sign that 200 tasks and 200 comments written by Bob and Carol went with it. Bob's own project 2 and its 10 tasks were untouched, because nothing pointed at the deleted row.

Then we tried to make the child tables stricter. None of it stopped the cascade:

  • No DELETE grant at all. We revoked DELETE on tasks and comments from authenticated and dropped the delete policies. A direct delete from tasks now failed with permission denied for table tasks. Alice's project delete still removed all 300 tasks and 300 comments.
  • FORCE ROW LEVEL SECURITY with a non superuser owner. We gave tasks and comments to an owner role without superuser or BYPASSRLS, turned on FORCE ROW LEVEL SECURITY, and wrote no policy for that owner. The owner itself now saw 0 tasks. The cascade still deleted all 300.

A BEFORE DELETE trigger on tasks showed who the cascade runs as. It fired once for each of the 300 rows, and inside it current_user was the owner of the tasks table, current_setting('role') was still authenticated, and auth.uid() was still Alice's id. Keep that in mind for the trigger fix below.

The same thing happens on a parent that the anon key can delete. With a using (true) delete policy and a read policy on projects for anon, and no grants on tasks or comments for anon at all, one signed out request removed both projects, all 310 tasks, and all 310 comments. The response was 204 with a count of 2.

Find every cascade in your project

Run this in the Supabase SQL editor. It lists every foreign key in public with ON DELETE CASCADE, whether anon or authenticated can delete the parent table, and how many delete policies the parent has:

select c.conrelid::regclass as child_table,
       c.confrelid::regclass as parent_table,
       c.conname as constraint_name,
       has_table_privilege('anon', c.confrelid, 'DELETE') as anon_can_delete_parent,
       has_table_privilege('authenticated', c.confrelid, 'DELETE') as authenticated_can_delete_parent,
       (select count(*) from pg_policy p
         where p.polrelid = c.confrelid and p.polcmd in ('d', '*')) as parent_delete_policies
from pg_constraint c
where c.contype = 'f'
  and c.confdeltype = 'c'
  and c.connamespace = 'public'::regnamespace
order by 2, 1;

The pg_constraint docs list the codes: a no action, r restrict, c cascade, n set null, d set default. Change the filter to confdeltype in ('c', 'n', 'd') to also see SET NULL and SET DEFAULT, which change child rows the same way.

How to read the result:

  • Parent deletable by anon: fix this first. Anyone with your publishable key can start the cascade if a policy lets them.
  • Parent deletable by authenticated, child rows written by other users: the pattern from our test. Teams, projects, workspaces, boards, channels, and shared folders are the usual suspects.
  • Child holds only the parent owner's own rows, such as a user's settings or drafts: a cascade is usually what you want.
  • A public table that references auth.users with on delete cascade is common in Supabase examples. Your client cannot delete from auth.users, so that cascade starts only when a user is deleted from the server or dashboard.

Fix 1: RESTRICT or NO ACTION on shared parents

Switch the foreign key so a parent with children cannot be deleted. Drop and re-add the constraint in one migration:

alter table public.tasks
  drop constraint tasks_project_id_fkey,
  add constraint tasks_project_id_fkey
    foreign key (project_id) references public.projects (id)
    on delete restrict;

With restrict or no action, Alice's delete failed with HTTP 409, code 23503, update or delete on table "projects" violates foreign key constraint "tasks_project_id_fkey" on table "tasks". Nothing was deleted.

Two things we saw that are worth knowing:

  • The check counts rows the user cannot see. Alice deleted her own 100 tasks first and tried again. The delete still failed, because Bob's and Carol's tasks were there, though she could not read them. On a project with only her tasks, deleting her tasks and then the project worked.
  • The error leaks a little. It tells the user that hidden rows exist. The Postgres row security docs call this a "covert channel" and say to take care to avoid such leaks through referential integrity checks. In our test the error detail did not include the hidden rows' values.

Supabase's cascade deletes guide explains the difference: RESTRICT raises the error immediately, while NO ACTION (the default when you write no on delete clause) can be deferred to the end of the transaction with INITIALLY DEFERRED. For a client facing delete, either one blocks the wipe.

Fix 2: delete through a function with checks

If the app really needs a delete button for shared parents, take DELETE on the parent away from client roles and put the rules in one function:

revoke delete on public.projects from anon, authenticated;

create function public.delete_project(p_id bigint)
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
  if not exists (
    select 1 from public.projects
    where id = p_id and owner_id = (select auth.uid())
  ) then
    raise exception 'not allowed' using errcode = '42501';
  end if;

  if exists (
    select 1 from public.tasks
    where project_id = p_id and owner_id <> (select auth.uid())
  ) then
    raise exception 'project has tasks from other members' using errcode = 'P0001';
  end if;

  delete from public.projects where id = p_id;
end;
$$;

revoke execute on function public.delete_project(bigint) from public, anon;
grant execute on function public.delete_project(bigint) to authenticated;

The client calls supabase.rpc('delete_project', { p_id: 1 }). In our test:

  • A direct delete() on projects from Alice failed with permission denied for table projects.
  • Bob calling delete_project(1) got not allowed.
  • Alice calling it while Bob's and Carol's tasks existed got project has tasks from other members, and nothing was deleted.
  • After the other users' tasks were gone, Alice's call deleted the project and her tasks.
  • A signed out caller got permission denied for function delete_project.

Change the second check to fit your product. You might move other members' tasks to a new owner, archive them, or require an admin. What matters is that the rule is written down in one place instead of left to the cascade. Supabase's database functions guide says you must set search_path when you use security definer, and our SECURITY DEFINER functions guide covers revoking EXECUTE so anon cannot call it.

An Edge Function works the same way: the browser sends the request with the user's session, the function verifies the user, applies the same checks, and only then deletes with a server side key. Our Edge Functions auth guide shows how to verify the caller. The secret key never goes to the browser.

Fix 3: soft delete the parent

When users expect to delete things but you cannot lose collaborators' work, mark the parent as deleted instead. Supabase's guide to deleting data safely describes this soft delete pattern with a deleted_at column. Tested setup:

alter table public.projects add column deleted_at timestamptz;

revoke delete, update on public.projects from authenticated;
grant update (deleted_at) on public.projects to authenticated;

create policy "read live projects" on public.projects
  for select to authenticated using (deleted_at is null);

create policy "owner reads own projects" on public.projects
  for select to authenticated using ((select auth.uid()) = owner_id);

create policy "owner soft deletes project" on public.projects
  for update to authenticated
  using ((select auth.uid()) = owner_id)
  with check ((select auth.uid()) = owner_id);

Drop any old project read policy that uses true first, or deleted projects stay visible. Results:

  • Alice's hard delete failed with permission denied for table projects.
  • Her update projects set deleted_at = now() on project 1 changed 1 row. The same update on Bob's project changed 0.
  • An update of name failed with permission denied, because only deleted_at is granted.
  • Bob no longer saw project 1. All 300 tasks and 300 comments were still there.

Keep the owner read policy. Without it, the owner's own soft delete can fail with new row violates row-level security policy, as we found in the deleted rows guide. Your app also has to filter child rows of deleted projects in its queries, and a scheduled server job can hard delete old projects later through a path that decides what happens to everyone's tasks.

Fix 4: a trigger that refuses cross user cascades

If you have to keep the cascade, you can block the part that hurts. Because the trigger still sees the request's role and auth.uid() during a cascade, it can refuse to delete rows that belong to someone else when the delete came from the API:

create function public.block_cross_user_cascade()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
  if current_setting('role', true) in ('anon', 'authenticated')
     and old.owner_id is distinct from (select auth.uid()) then
    raise exception 'cannot delete rows owned by another user' using errcode = '42501';
  end if;
  return old;
end;
$$;

create trigger block_cross_user_cascade
  before delete on public.tasks
  for each row execute function public.block_cross_user_cascade();

In our test, Alice's project delete then failed with cannot delete rows owned by another user and the whole statement rolled back: the project, all 300 tasks, and all 300 comments stayed. Alice could still delete her own 100 tasks directly. A delete as service_role and a delete from a direct postgres connection were not blocked, so server cleanup still works.

Add the trigger to every child table in the chain that holds other users' rows. It is a safety net, not a replacement for Fix 1 or Fix 2, and it depends on your app setting role the way Supabase's API does.

Fix 5: SET NULL, with its tradeoffs

ON DELETE SET NULL keeps the child rows and clears the foreign key:

alter table public.tasks
  drop constraint tasks_project_id_fkey,
  add constraint tasks_project_id_fkey
    foreign key (project_id) references public.projects (id)
    on delete set null;

In our test, Alice's project delete kept all 300 tasks with project_id set to null, and Bob still saw his 100. Nobody lost data. But know what you get:

  • It also skips the child table's rules. It worked with no UPDATE grant on tasks for authenticated.
  • It leaves orphans. Your app needs a plan for tasks with no project.
  • It fails on NOT NULL columns, and the error can leak data. With project_id marked not null, the delete failed with HTTP 400, code 23502, and the error details sent to Alice were Failing row contains (1, null, bbbbbbbb-..., task 1): the id, owner and title of one of Bob's tasks, which RLS hid from her. Our error details guide shows the other places Postgres errors leak hidden rows and how to return safe errors instead.

The CREATE TABLE docs also allow SET NULL (column_name) to clear only some columns of a multi column key.

Revoke DELETE where the browser has no business

The grants are the outer wall for all of this. The Supabase Row Level Security docs say anon and authenticated get every privilege on new tables automatically, though not every project does this, and from October 30, 2026 new tables need explicit grants (see our 42501 guide). Either way, take DELETE away where it is not needed:

revoke delete, truncate on all tables in schema public from anon;
revoke delete on public.projects from authenticated;

Keep DELETE for authenticated only on tables where a user deletes their own rows and nothing that belongs to anyone else hangs off them. The deleted rows guide has a query that lists every table and view the client roles can delete, plus a trigger that copies deleted rows into a private table. We put that exact trigger on tasks and comments and deleted Alice's project: it logged all 300 tasks and all 300 comments, each with role authenticated and Alice's user id. So it also catches what a cascade removes, and shows who started it.

Common AI generated mistakes

  • on delete cascade on every foreign key because it makes delete buttons "just work".
  • A delete policy on the parent that checks only owner_id, with collaborators' rows hanging off it.
  • Trusting the child table's RLS to protect collaborators' rows from a parent delete.
  • "Fixing" a 23503 error by switching the key to cascade, when the error was the database protecting other users' data.
  • Switching to set null on a not null column, which breaks the delete and puts hidden row contents in the error.

Checklist

  • Every ON DELETE CASCADE in public is listed, with whether its parent is deletable by anon or authenticated
  • No cascade starts from a table anon can delete
  • Shared parents (teams, projects, workspaces) use RESTRICT or NO ACTION, or are not deletable from the client
  • Deletes of shared parents go through a function or Edge Function that checks other users' rows
  • Soft deletes use a column level UPDATE grant and keep an owner read policy
  • SET NULL is used only on nullable columns, with a plan for orphaned rows
  • Deleted rows, including cascaded ones, are copied to a private table by a trigger
  • A test proves that deleting a parent cannot remove another user's rows

For grants and policies across the whole project, use the Supabase RLS checklist. To keep this from coming back after the next migration, add the test to CI with our RLS testing guide.

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Codex, Lovable, Bolt, or Replit inside your own project. It asks for SQL and file names only. Do not paste database passwords, connection strings, or keys into the chat.

This project uses Supabase. Do not print, log, or ask me for any secret values. 1) List every foreign key in the public schema with ON DELETE CASCADE, SET NULL, or SET DEFAULT, using pg_constraint confdeltype, and for each one show the parent table, whether the anon or authenticated role has DELETE on the parent, and the parent's DELETE policies. 2) For each one, tell me whether the child table can hold rows owned by a different user than the parent's owner. 3) For those shared parents, write one migration that switches the foreign key to ON DELETE RESTRICT, revokes DELETE on the parent from anon and authenticated, and adds a security definer function with set search_path = '' that checks the caller owns the parent and decides what happens to other users' child rows, with EXECUTE revoked from public and anon. 4) Where users need a delete button but data must survive, add a deleted_at soft delete column with a column level UPDATE grant and an owner read policy. 5) Find every client delete() call on those parent tables and switch it to the function. 6) Write a test that signs in as one user, deletes a shared parent, and proves another user's child rows still exist. Do not run the migration until I approve it.

More prompts are in the security review prompt guide.

Check what your live app exposes with LeakCheck

LeakCheck scans any live app URL or public GitHub repo, read only, wherever it is hosted. On a live app that uses Supabase, it finds the key your app ships and checks whether that key can list rows from your tables, alongside its other checks for exposed files and leaked keys.

Its limits, honestly: it never sends DELETE requests and cannot see your foreign keys, grants, or policies, so it cannot tell you whether a cascade can wipe data. The catalog query above is how you check that.

Sources

Questions

Does ON DELETE CASCADE bypass RLS in Supabase?

On the child table, yes. The PostgreSQL row security docs say referential integrity checks, such as foreign key references, always bypass row security. In our test the cascade deleted every child row even when the deleting user could not see those rows, had no DELETE grant on the child table, and the child table had FORCE ROW LEVEL SECURITY with a non superuser owner. RLS still applies to the parent row the user deletes directly.

Can a user delete other users' rows with a cascade?

Yes, if they are allowed to delete a parent row that other users' rows point to. In our test, a project owner deleted her one project row through the API and that removed 200 tasks and 200 comments written by two other users, even though a direct delete of those tasks matched 0 rows for her. The API response only showed the one project row.

Should I use ON DELETE RESTRICT instead of CASCADE?

For parents that hold other users' data, RESTRICT or the default NO ACTION is safer, because the delete fails with error 23503 while child rows exist. In our test the check also counted rows the user could not see, so the delete stayed blocked until those rows were gone. That is the covert channel the Postgres docs warn about: the error tells the user hidden rows exist. Then delete through a server path that decides what happens to everyone's rows.

Is ON DELETE SET NULL safer than CASCADE?

It keeps the child rows, so nobody loses data, but it also runs without the user's grants or policies on the child table and leaves orphaned rows. In our test 300 tasks kept their owners and lost their project_id, with no UPDATE grant on tasks. If the column is NOT NULL, the delete fails with 23502, and in our test the error details sent to the client included the full content of another user's row.

How do I find every ON DELETE CASCADE in my Supabase database?

Query pg_constraint for foreign keys with confdeltype = 'c' in the SQL editor. The query in this guide also shows whether anon or authenticated can delete the parent table and how many DELETE policies the parent has. Any cascade whose parent is deletable from the client deserves a second look.