HomeGuidesSupabase rows deleted? Recover them and lock DELETE down

Guide

Supabase rows deleted? Recover them and lock DELETE down

Someone wiped a Supabase table with your public key. What daily backups, PITR, and the Free plan can recover, and the grants and policies that block it.

20 min read

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

You open your app and the profiles are gone. Or the logs show thousands of DELETE requests to /rest/v1/profiles that you never sent. Someone took the public key from your frontend and asked your database to delete rows, and the database said yes.

This guide covers both halves of that day: how to get the rows back (daily backups, Point in Time Recovery, what Free plan projects can and cannot do) and how to make DELETE impossible for the anon key (grants, policies, views, TRUNCATE, soft deletes, and a deleted rows log). Every SQL block below was run on PostgreSQL 17 behind PostgREST with Supabase style anon, authenticated, and service_role roles, and every Supabase plan detail is from the docs and pricing page linked at the end.

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

The short version

  • Paid plan: do not restore in place first. Use Restore to a new project from a backup or PITR point before the deletion, then copy only the missing rows back.
  • Free plan: Supabase keeps no backups for you. Only your own dump can bring the rows back.
  • Stop the bleeding first: revoke DELETE and TRUNCATE from anon on every table and view, and drop any delete policy that says using (true).
  • Check views: a simple view is updatable, and by default it uses the owner's rights and policies. A delete through it can skip RLS.
  • service_role and secret keys stay on the server. They bypass RLS by design.
  • Add a net: soft deletes with deleted_at, plus a trigger that copies every deleted row into a private table.

What actually happened in these wipes

On October 23, 2026, the founder of Maxed AI posted on X that someone sent 3,500 delete requests and wiped out 95% of the app's profiles. In a follow up post he said 35,000 profiles were wiped, and explained the cause in his own words: he had bypassed RLS with a view so users could see each other's profiles, and had not revoked Supabase's default anon permissions, so the public key could delete everyone through it.

We rebuilt that shape to see what it takes. A profiles table with RLS on, a single owner only SELECT policy, and no DELETE policy at all. Then one view, created the normal way:

create view public.public_profiles as
  select id, username, bio from public.profiles;

With the old default grants still in place, one signed out request with the anon key deleted all 1,000 test rows through the view:

await supabase.from('public_profiles').delete().not('id', 'is', null)

The same request against the table itself deleted nothing. Two Postgres rules explain it. The CREATE VIEW docs say simple views are automatically updatable, so INSERT, UPDATE, DELETE, and MERGE work on them like on a table. The same page says that by default the view owner's permissions and row level security policies are applied to the base table. On Supabase that owner is usually postgres, and the Postgres RLS docs say table owners normally bypass row security.

Our guide on Supabase views and functions that bypass RLS covers the read side of this. This guide is about deletes.

First hour: stop more deletes

Do this before you think about restoring. A restore is wasted if the same request can run again.

  1. Find what is deletable. Run this in the SQL editor. It lists every table and view in public that anon or authenticated can delete, truncate, or update:
select c.relname as name,
       case c.relkind when 'v' then 'view' else 'table' end as kind,
       r.rolname as role,
       has_table_privilege(r.rolname, c.oid, 'DELETE') as can_delete,
       has_table_privilege(r.rolname, c.oid, 'TRUNCATE') as can_truncate,
       has_table_privilege(r.rolname, c.oid, 'UPDATE') as can_update
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
cross join (values ('anon'), ('authenticated')) as r(rolname)
where n.nspname = 'public'
  and c.relkind in ('r', 'p', 'v')
  and (has_table_privilege(r.rolname, c.oid, 'DELETE')
    or has_table_privilege(r.rolname, c.oid, 'TRUNCATE')
    or has_table_privilege(r.rolname, c.oid, 'UPDATE'))
order by 1, 3;
  1. Take DELETE and TRUNCATE away from signed out visitors, everywhere. ALL TABLES IN SCHEMA covers views too. In our test it removed the delete grant from the view as well:
revoke delete, truncate on all tables in schema public from anon;
revoke truncate on all tables in schema public from authenticated;
  1. Make views read only. A view that exists so people can read data has no reason to accept writes:
revoke insert, update, delete on public.public_profiles from anon, authenticated;
alter view public.public_profiles set (security_invoker = true);

With the revoke, the same delete through the view returned 42501 permission denied for view public_profiles and all 1,000 rows stayed.

  1. Find the delete policies. Any DELETE or ALL policy with qual = true, or with roles = {public}, is a door:
select tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
  and cmd in ('DELETE', 'ALL', 'UPDATE')
order by tablename, policyname;

Drop the open ones, for example drop policy "Enable delete for all users" on public.profiles;. Our guide on using (true) and permissive policies explains why one true policy beats every strict one next to it.

  1. Save the logs now. The pricing page lists API and database log retention of 1 day on Free, 7 days on Pro, and 28 days on Team. Look at the API logs in the dashboard for the DELETE requests, note the time of the first one, and export what you need. That timestamp is your restore target.

  2. If a secret key or service_role key was exposed, rotate it. Revoking grants does nothing against a key that bypasses RLS. See exposed Supabase key: what to do now.

Can you get the rows back? It depends on your plan

Plan Daily backups Point in Time Recovery What you can do after a wipe
Free Not included Not included Only your own dumps
Pro Last 7 days Paid add-on Restore, or restore to a new project and copy rows back
Team Last 14 days Paid add-on Same as Pro
Enterprise Up to 30 days Paid add-on Same as Pro

Sources: the Supabase Database Backups docs and pricing page. Two details from the backups page matter here:

  • Daily means daily. The docs say that even with daily backups you could still lose a day's worth of data.
  • Backups cover the database, not files. Objects you store through the Storage API are not in database backups, and restoring an old backup does not restore files deleted after it.

You can restore the live project to a backup from Database > Backups. The backups docs say the project is inaccessible during the restore and downtime grows with database size. It also puts the whole database back to that moment, so every signup, order, and edit after the backup is gone.

The safer path is the Restore to a New Project tab on the same page. The restore to a new project docs describe it as a database only copy that includes schema, data, roles, and Auth users, and say it is available on paid plans with physical backups enabled. The new project costs extra, and Supabase shows the cost before it starts. The same page calls it the safe way to get deleted rows back. Read two warnings there first:

  • It is a binary copy, so extensions that call out, such as pg_cron, pg_net, and wrappers, start running as soon as the restore completes. A cron job that emails users or charges cards will run in the copy too.
  • Recovering deleted rows by inspecting dead tuples with tools such as pageinspect or pg_surgery is not supported on Supabase and can corrupt data.

Once the copy exists, move only the missing rows. Export them from the restored project with psql connected to it:

\copy (select id, username, bio from public.profiles) to 'profiles_backup.csv' csv header

Then, connected to the live project, load them into a scratch table outside the exposed schema and insert what is missing:

create schema if not exists private;
create table private.profiles_restore (like public.profiles);
\copy private.profiles_restore (id, username, bio) from 'profiles_backup.csv' csv header

insert into public.profiles (id, username, bio)
select id, username, bio from private.profiles_restore
on conflict (id) do nothing;

drop table private.profiles_restore;

In our test this brought back 1,000 wiped rows and kept a row created after the wipe. on conflict (id) do nothing is what keeps newer rows safe. The CSV holds your users' data, so delete it when you are done and do not commit it.

Point in Time Recovery

PITR replays the write ahead log to any second inside the retention window. The backups docs say it is an add-on for Pro, Team, and Enterprise, needs at least a Small compute add-on, and replaces daily backups while it is on. They put the worst case recovery point at two minutes. The listed price is about $100 a month for 7 days of retention, about $200 for 14, and about $400 for 28. The PITR usage page adds that it is billed by the hour and is not covered by the Spend Cap.

PITR only covers time after it was turned on. For a table that real users depend on, it is the difference between losing a day and losing two minutes.

Free plan: what you can and cannot do

The pricing page lists automatic backups and Point in Time Recovery as not included on Free, and the backups docs recommend that Free projects regularly export their data with supabase db dump and keep off site copies. So:

  • You cannot restore from a Supabase backup, because there is none for your project.
  • Upgrading now starts backups from now. It cannot back up a moment that already passed.
  • You can restore your own dump if you have one. Follow Supabase's Backup and Restore using the CLI guide to load it into a new project, then copy the missing rows back with the SQL above.
  • If you have no dump, look for other copies: CSV exports you made, a staging database seeded from production, analytics events, or your payment provider's customer list. If the wiped rows were created from Auth signups, the accounts in auth.users may still exist, so the app can recreate empty profile rows for them. The content the users wrote is gone.
  • Tell your users what happened and what was lost. If personal data could also have been read, check what you owe them under the privacy laws that apply to you.

Back up before you need it

On any plan, keep your own copy. The CLI guide uses three commands. Keep the connection string in an environment variable, never in a file in your repo:

supabase db dump --db-url "$SUPABASE_DB_URL" -f roles.sql --role-only
supabase db dump --db-url "$SUPABASE_DB_URL" -f schema.sql
supabase db dump --db-url "$SUPABASE_DB_URL" -f data.sql --use-copy --data-only

The guide's data command also excludes two Storage vector tables with -x. Copy the current version from that page. Things to know from the db dump reference:

  • The default dump has no data. You need --data-only for rows.
  • It runs pg_dump in a Docker container with flags that exclude Supabase managed schemas such as auth and storage. Open the files and check that your tables are there.
  • Plain pg_dump also works against the same connection string if you want a custom format file you can restore one table from.

Habits that make the dump useful on a bad day:

  • Run it on a schedule from a machine or CI job you control, not by hand.
  • Store dumps encrypted, outside your repo, and outside the Supabase project. They contain user data.
  • Restore one into a scratch project now and then. A backup you have never restored is a guess.
  • Dumps do not include Storage files. Back those up separately if they matter.

Lock DELETE down for good

The first hour fixes stop today's attack. These make the fix stick.

Grants first, then policies

The Supabase Securing your API guide describes two layers: grants decide which roles can reach a table, and RLS decides which rows. On existing projects, new tables in public have been granted select, insert, update, delete, truncate, and more to anon, authenticated, and service_role. From October 30, 2026, new tables stop getting those grants, but tables that already exist keep them.

Grants are checked before policies. In our test:

Setup Anon delete with a match all filter
DELETE granted, policy using (true) All 1,000 rows deleted
DELETE granted, no delete policy 0 rows deleted, no error
DELETE revoked, policy using (true) still there 42501 permission denied, 0 rows
DELETE revoked, policy dropped 42501 permission denied, 0 rows

So revoking the grant is the stronger fix, and dropping the policy is the second lock. Do both. For new tables on projects still on the old defaults, stop the grant at the source:

alter default privileges for role postgres in schema public
  revoke delete, truncate on tables from anon;
alter default privileges for role postgres in schema public
  revoke truncate on tables from authenticated;

We confirmed a table created afterward had no delete or truncate grant for anon.

If users really delete their own rows

Some apps need a real delete, for example removing your own note. Keep the grant for authenticated only, on that table only, with an owner check:

revoke delete on public.notes from anon;
grant delete on public.notes to authenticated;

create policy "Owners delete their own notes"
on public.notes for delete
to authenticated
using ((select auth.uid()) = user_id);

The RLS docs show the same pattern for delete policies. Never write to public or leave out the to clause on a delete policy, and never use using (true). Remember that authenticated means anyone who can sign up. If sign up is open, an owner check is the only thing between one account and every row.

TRUNCATE ignores RLS

The Postgres RLS docs say operations that apply to the whole table, such as TRUNCATE, are not subject to row security. The Data API does not send TRUNCATE, but the Securing your API guide notes that SQL running as anon or authenticated, such as a database function, can use those leftover privileges. In our test, a security invoker function that ran truncate public.profiles emptied the table when anon called it through rpc(), even with RLS on and no policies. After revoke truncate, the same call failed with 42501. The TRUNCATE docs add that it does not fire ON DELETE triggers, so a delete log will not see it either.

"DELETE requires a WHERE clause" is not protection

If you call delete() with no filter, the Data API answers with error 21000, DELETE requires a WHERE clause, as reported in this supabase-py issue where a Supabase maintainer recommends using filters. That is the pg-safeupdate behavior. PostgREST's pg-safeupdate page is clear about its limit: it does not protect against malicious actions, because someone can add a URL parameter that does not affect the result set, and to prevent that you must use database permissions. Our anon test used .not('id', 'is', null), which matches every row.

Keep service_role on the server

The Supabase RLS docs say a secret key works through the service_role Postgres role, which has the bypassrls attribute, and must never be used in the browser. The legacy service_role key is the same. One catch from that page: a secret key bypasses RLS only when the request carries no user access token. Our anon key vs service role key guide covers where each one belongs. In our test the service role deleted a row on a table where anon and authenticated could not. That is fine on a server. In a browser bundle, an .env file that ships to the client, or a public repo, it undoes every fix above.

Soft deletes: make "delete" recoverable

Supabase's deleting data safely guide suggests a soft delete pattern when you want the option to recover: add a deleted_at timestamptz column and set it instead of deleting. The row stays, so undo is one update.

A soft delete moves the risk from DELETE to UPDATE. If anyone can update any row, anyone can soft delete everything. This setup held up in our test:

alter table public.profiles add column deleted_at timestamptz;

-- No client role can hard delete or rewrite whole rows
revoke delete, truncate, update on public.profiles from anon, authenticated;

-- Signed in users can change only these columns, and only on their own row
grant update (username, bio, deleted_at) on public.profiles to authenticated;

create policy "Owner can update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);

-- Public reads skip soft deleted rows
create policy "Read profiles that are not deleted"
on public.profiles for select
to anon, authenticated
using (deleted_at is null);

-- Owners can still see their own row, including after a soft delete
create policy "Owner can read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);

What we saw:

  • The owner set deleted_at on their own row. A second user's "soft delete everything" request changed only their own row. Anon updates failed with 42501.
  • Soft deleted rows disappeared from public reads.
  • The column grant blocked attempts to change id.
  • Without the "Owner can read own profile" policy, the owner's soft delete failed with new row violates row-level security policy, even without .select(). The updated row no longer passed the only SELECT policy. Keep an owner read policy next to a deleted_at is null filter.

If you add a view for active rows, as the Supabase guide shows, create it with (security_invoker = true) and revoke writes on it, or you are back to the view problem from the top of this guide. Pair soft deletes with a scheduled job that hard deletes old rows from the server, so "deleted" data does not live forever.

Keep a copy of every deleted row

A trigger can copy each deleted row into a table the API cannot reach. If a delete slips through anyway, the data is one query away.

create schema if not exists private;
revoke all on schema private from public, anon, authenticated;

create table private.deleted_rows (
  id bigint generated always as identity primary key,
  table_name text not null,
  row_data jsonb not null,
  deleted_by_role text,
  deleted_by_uid uuid default auth.uid(),
  deleted_at timestamptz not null default now()
);

create or replace function private.log_deleted_row()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into private.deleted_rows (table_name, row_data, deleted_by_role)
  values (tg_table_schema || '.' || tg_table_name, to_jsonb(old), current_setting('role', true));
  return old;
end;
$$;

revoke all on function private.log_deleted_row() from public, anon, authenticated;

create trigger log_profiles_delete
  after delete on public.profiles
  for each row execute function private.log_deleted_row();

In our test, when anon deleted 1,000 rows on a still open table, all 1,000 landed in private.deleted_rows with deleted_by_role = anon. The private schema was not reachable through the API, and the function could not be called with rpc(). Putting them back took one statement:

insert into public.profiles
select (jsonb_populate_record(null::public.profiles, row_data)).*
from private.deleted_rows
where table_name = 'public.profiles'
on conflict (id) do nothing;

Why each line is there:

  • security definer lets the trigger write to a table the caller cannot touch. set search_path = '' with schema qualified names stops a caller from swapping in their own objects. The Postgres trigger docs describe OLD as the row being deleted.
  • It does not see TRUNCATE, so revoke that grant as shown above.
  • The log holds personal data. Keep it out of exposed schemas and delete old entries on a schedule.
  • If you add columns to the table later, the restore query still works, because jsonb_populate_record fills missing keys with null.

Watch for the next one

  • Run the grants query from the first hour again after every migration, or put it in CI. Our guide on testing Supabase RLS policies in CI shows how to assert that anon cannot delete.
  • Count rows on your important tables once a day from a server job and alert on a big drop.
  • Run Security Advisor in the dashboard and fix the errors. Our Security Advisor guide explains each lint.
  • Check every view with the queries in our views guide.
  • List your on delete cascade foreign keys. One allowed delete on a parent row removes every child row, including rows other users own, and RLS on the child table does not stop it. Our ON DELETE CASCADE guide has the catalog query and tested fixes.

Checklist

For the full table by table review, use the Supabase RLS checklist.

  • Anon has no DELETE or TRUNCATE grant on any table or view in public
  • Authenticated has no TRUNCATE grant, and DELETE only where users delete their own rows
  • Default privileges revoke the same grants for new tables
  • No DELETE or ALL policy uses using (true) or applies to public
  • Every view is read only for client roles and uses security_invoker = true
  • No database function callable by anon deletes or truncates
  • service_role and secret keys exist only on servers
  • Soft deletes for user content, with column level UPDATE grants and an owner read policy
  • A trigger copies deleted rows into a private table
  • Daily backups or PITR on paid plans, scheduled supabase db dump on any plan, and one test restore done

Common AI generated mistakes

  • "Enable delete for all users" with using (true), added to make a delete button work.
  • A view to "show other users' profiles" with no revoke on writes and no security_invoker.
  • Fixing a 42501 on delete with grant all ... to anon.
  • Putting the service role key in client code so a delete works from the browser.
  • Soft delete with a broad update policy, so any user can set deleted_at on every row.
  • Assuming the Free plan has backups.

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 table and view in the public schema that the anon or authenticated role can DELETE, TRUNCATE, or UPDATE, using has_table_privilege, and list every DELETE, UPDATE, or ALL policy from pg_policies with its roles and using clause. 2) Flag any policy that uses true, applies to public, or does not compare a user id column to (select auth.uid()). 3) Flag every view that is not security_invoker = true or that still grants INSERT, UPDATE, or DELETE to anon or authenticated. 4) Flag every function anon or authenticated can execute that runs DELETE or TRUNCATE. 5) Search the client code for the service role key or a secret key and for any delete() call without an ownership filter. 6) Write one migration that revokes DELETE and TRUNCATE from anon on all tables and views, revokes TRUNCATE from authenticated, sets matching default privileges, makes views read only with security_invoker, keeps owner only delete policies only where the app needs them, and adds a deleted_at soft delete column with column level UPDATE grants where it fits. 7) Add an AFTER DELETE trigger that copies deleted rows into a private schema table that the API cannot reach. 8) Write tests that prove a signed out request cannot delete from any table or view. 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, UPDATE, or TRUNCATE requests, so it cannot prove your tables are safe from deletes, and it cannot see your grants, views, or functions. A clean scan is not proof that DELETE is locked. The grants and policy queries above are how you prove that.

Sources

Questions

Can I restore deleted rows on the Supabase Free plan?

Not from Supabase backups. The Supabase pricing page lists automatic backups and Point in Time Recovery as not included on Free, and the backups docs tell Free projects to export their data regularly with supabase db dump and keep off site copies. If you have your own dump, restore it into a new project and copy the missing rows back. If you do not, look for other copies of the data, such as your own exports, analytics, or payment provider records. Upgrading after the deletion does not create a backup of a moment that already passed.

Will restoring a Supabase backup undo everything since the backup?

An in place restore puts the whole project back to that backup, so rows written after it are lost, and the project is offline while it runs. On paid plans the safer path is Restore to a new project: Supabase creates a separate project from the backup, and you copy only the deleted rows back into your live project with insert ... on conflict do nothing.

How did someone delete rows with only my anon key?

Three common ways. The table or view still has the DELETE grant that older Supabase projects give anon and authenticated by default, and a policy allows the delete, such as using (true). Or a simple view over the table is security definer by default, so a delete through the view runs with the owner's rights and skips RLS. Or a database function callable by anon runs the delete or a TRUNCATE. In our test, a view over a table with RLS on and no delete policy let a signed out request delete all 1,000 rows.

Does Supabase block a DELETE without a filter?

The Data API rejects a delete with no filter with error 21000, DELETE requires a WHERE clause. That only stops accidents. PostgREST's own docs say it does not protect against malicious actions, because anyone can add a filter that matches every row. In our test, delete().not('id', 'is', null) as anon removed every row. Only grants and policies stop it.

Is a soft delete with a deleted_at column enough?

It makes recovery easy, because the row is still there, but it moves the risk from DELETE to UPDATE. If anon or every signed in user can update any row, they can soft delete everything. Give only authenticated a column level UPDATE grant, add an owner only update policy, and revoke DELETE from the client roles.