Anyone can delete rows in my Supabase table: how to fix it
If anyone can delete rows in your Supabase table, RLS is off for that table or a delete policy allows every caller. To fix it, revoke the delete privilege from the anon role, enable RLS, and add a delete policy that checks the owner of the row. Then restore the lost rows from a backup, if you need them.
This guide covers the delete problem only. It shows how to confirm the hole, how to stop it in minutes, and how to check what was deleted. For the full RLS method, read the Supabase RLS guide.
Why can anyone delete rows in my Supabase table?
Supabase makes each table in an exposed schema available as a REST endpoint. A caller sends an HTTP DELETE request to the endpoint, and the database removes the matching rows. The request needs only the project URL and the anon key. Both are public, because the browser must have them.
Row Level Security (RLS) is the control that limits the request. The Supabase RLS documentation states that a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Delete is a write.
The delete hole has three common causes:
| Cause | What happens |
|---|---|
| RLS is off | No rule applies. Any role with a delete grant removes any row. |
A policy uses using (true) for delete |
The rule applies, but it allows every row for the named role. |
The policy has no to clause, or the clause names anon |
Anonymous callers match the policy. |
The Supabase linter reports the first cause as lint 0013_rls_disabled_in_public. The lint description says that anyone with your project URL can read, edit and delete all data in the table. The linter reports the second cause as lint 0024_permissive_rls_policy, a warning for always-true conditions.
AI tools often create the second cause. You ask for a delete button. The app fails with a permission error. The tool then adds a broad policy, such as for delete using (true), and the error disappears. The app works, and the hole stays open.
The delete request is more dangerous than a read request in one way. A read leaves the data in place. A delete removes it, and nothing in the table shows who did it.
How do I confirm that anyone can delete my rows?
Test with the anon key, as an attacker does. Use a test row. Do not run this test on real user data.
Warning: A delete test removes data. Create a row for the test, and use the id of that row only.
-
Create a test row. Insert one row in the SQL Editor. Give the row a clear value, such as
delete-test. Write down itsid. -
Send the delete request with the anon key. Use the key that your deployed frontend ships:
curl -X DELETE "https://<project-ref>.supabase.co/rest/v1/<table>?id=eq.<test-row-id>" \ -H "apikey: <anon-key>" \ -H "Authorization: Bearer <anon-key>" -
Read the row again. Run a read request for the same id. Use the SQL Editor, because the anon read can also be blocked:
select * from public.<table> where id = <test-row-id>; -
Read the result. If the row is gone, an anonymous caller can delete rows. If the row is still there, the anon key cannot delete from this table.
-
Repeat as a signed-in user. Sign in as a normal test user and send the same request with the token of that user. Use a row that belongs to a second user. If the row is gone, the policy does not check the owner.
Do this test for each table that holds data. Start with the tables for users, orders, messages and files.
You can also look at the policies. Open Database, then Policies, and read each delete policy. Look for these signs:
- The word
truein theusingclause. - A policy that names the
publicrole oranon. - A table with RLS off. The dashboard marks it.
The Security Advisor also lists these tables. Open Advisors, then Security Advisor. The advisor documentation lists lints 0013 and 0024 as findings.
How do I stop the deletes right now?
Do the steps in this order. Step 1 stops the deletes in one command. The other steps fix the cause.
Caution: Step 1 can break the delete feature of your app for real users. This result is correct while the table is open. You restore the feature in step 4.
-
Revoke the delete privilege. Run this SQL for each open table. It removes the privilege from the two browser roles:
revoke delete on table public.todos from anon, authenticated;The RLS documentation says that a missing grant raises error
42501before any policy runs. For this reason, the revoke blocks the request even if a bad policy exists. Theservice_rolekey is not affected, so your server code still works. -
Enable RLS. Run this SQL for the table:
alter table public.todos enable row level security;RLS with no policy blocks all API access. You can also use Database, then Policies, then Enable RLS.
-
Remove each bad delete policy. Find the policy name in the Policies page. Then drop it:
drop policy "Anyone can delete" on public.todos; -
Add a delete policy that checks the owner. Do this only for the tables where users must delete rows. First grant the privilege again, but only to
authenticated:grant delete on table public.todos to authenticated; create policy "owner can delete" on public.todos for delete to authenticated using ((select auth.uid()) = user_id);The syntax follows the delete example in the RLS documentation. The
(select auth.uid())form is the one that the documentation recommends.auth.uid()returnsnullfor an unauthenticated request, andnull = user_idis never true. For this reason, the policy also blocks anonymous callers. -
Check the other write actions. An open table usually has open
updateandinsertactions too. Read the RLS guide for the matching policies. A caller that cannot delete a row can still change it. -
Run the test again. Repeat the delete test from the previous section. The anon request must leave the test row in place.
-
Test the app. Use the delete feature as a normal user. Make sure that each user can delete only their own rows.
If only the server must delete rows, skip steps 4 and 5. Leave the privilege revoked and let the server use the service_role key. Never put that key in the browser. See the service_role key guide.
Should users delete rows directly from the browser?
Often they should not. A direct delete is easy to build, but it makes the table the only line of defense. Use a different design when the data matters.
Soft delete. The app marks the row as deleted and keeps it. The user no longer sees the row, and you can undo a mistake. Add a column and change the delete action to an update:
alter table public.todos add column deleted_at timestamptz;
create policy "owner can soft delete" on public.todos
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
The RLS documentation states that an update policy needs both clauses and that a matching select policy is required. Your read policy must also hide rows where deleted_at has a value, so that the user does not see them.
Server route. The browser calls your own server code. The server checks the user and the rules, and then deletes the row. This design lets you add checks that a policy cannot express, for example “an invoice cannot be deleted after payment”.
No delete at all. Some tables must never lose rows, such as payments and audit logs. Keep the delete privilege revoked from anon and authenticated for these tables, and add no delete policy.
How do I find out what was deleted?
Start with the logs. Then compare the data with a backup. A delete leaves little trace in the table, so you need both sources.
- Open the API logs. In the dashboard, open the Logs page. The Supabase logs documentation describes the API Gateway logs and the Method and Pathname filters.
- Filter for DELETE. Set the method to
DELETE. Set the pathname to the REST path of the table, for example/rest/v1/todos. - Read the times. Find the first and last request. This gives you the time window of the damage.
- Read the callers. Look at the IP address and the user agent. Compare them with your own traffic. One source that sends many requests is a strong sign of abuse.
- Check the retention. The retention depends on your plan. If the window is short, the first requests may be gone.
- Count the rows. Compare the current row count with the count that you know from before. Check the tables that relate to the open table, because foreign keys can remove or block rows.
The logs do not list the content of the deleted rows. To get the content, you need a backup.
How do I restore the deleted rows?
Use a backup. The Supabase backups documentation gives the rules:
- Pro projects keep 7 days of daily backups. Team projects keep 14 days. Enterprise projects can access up to 30 days.
- Point-in-Time Recovery (PITR) is an add-on for Pro, Team and Enterprise projects. It has a recovery point of about two minutes.
- Free projects: the page recommends regular exports with
supabase db dump.
You restore a backup in the dashboard. Open Database, then Backups, and select the backup. The project is not available during the restore. A restore returns the whole database to the state at the backup time. It removes all changes made after that time, including new orders and new users.
Caution: Do not restore before you close the hole. If the table is still open, the same deletes can happen again.
The backup page also says that a restore does not bring back Storage objects that were deleted after the backup. The database holds only the metadata of these files.
If you have no backup, check your own copies. Look at CSV exports, email notifications, third-party tools that hold the same data, and any local development database. Ask your users for the data if the loss is small and the data is theirs.
How do I stop this from happening again?
Use these controls so that a table never opens to deletes again.
- Create each table with RLS in one migration. Put
create table,enable row level securityand the policies in the same file. - Grant only what each role needs. The API security guide says to grant only the needed privileges to
anon,authenticatedandservice_role. Do not givedeletetoanon. - Search for
using (true). Search your migrations and your dashboard policies for this text. Replace each match with an owner check, or remove it. - Tell your AI tool the rule. Add a line to the project rules: “Never write a policy with
trueto fix a permission error. Ask for the missing owner check instead.” - Use soft delete for important data. A mistake or an attack then becomes an update that you can undo.
- Take backups that you can restore. Test a restore at least one time, before you need it.
- Test as a different user. After each release, try to delete a row that belongs to another user. The request must fail or change nothing.
- Scan the deployed app. The scan sends real requests with the public key. It finds a table that the code review missed. See the BOLA pattern for the related ownership check problem.
When should I get help?
Get help in these cases:
- Rows are missing, and you have no backup. A database engineer can sometimes find data in the logs of the project.
- The deleted rows held payment data or personal data. You may have legal duties to your users.
- The delete test works for a signed-in user on rows of another user. The policy logic is wrong, and a wrong fix can leave the hole open.
- The attacker also read or changed data. Read what the anon key can access, and test the read and update actions as well.
- You find a
service_rolekey in the browser code. Treat the database as fully exposed, and rotate the key.
After the fix, run the anon delete test one more time. The test row must stay in the table.
Common questions
How can I see if an anonymous caller can delete my rows?
Does RLS with no delete policy block deletes?
Is a delete policy of using (true) safe?
Can I get my deleted rows back?
Should the delete button call the API directly?
Test delete access
A VibeEval scan sends write requests with the public Supabase key to the tables of your project and reports the tables that accept a change.
14-day free trial · No credit card · Cancel anytime