SaaS architecture
Supabase 'RLS Disabled in Public': what anon can do
What anon can do to a Supabase table without row-level security, measured on PostgreSQL 17 with Supabase's default privileges, and what the linter misses.
Supabase’s database linter flags a table with “RLS Disabled in Public” at level ERROR, and its documentation says what that means: “Anyone with your project URL can read, edit, and delete all data in this table because Row-Level Security is not enabled.” We measured it on PostgreSQL 17.11 with Supabase’s roles and the default privileges from its own setup script. As the anon role, the role a request with only the public key runs as, we could read all four rows, insert, update all four and delete all four. Enabling row-level security (alter table … enable row level security) stopped all of that. It did not stop one thing: anon also held TRUNCATE, which row-level security never checks, and in SQL emptied the table with RLS on and a policy in place. Whether the Data API offers a way to send it, we did not test.
What can anon do to a table without RLS?
Everything its privileges allow. Supabase’s setup script, supabase/postgres at commit d8666db, gives every new table in public to all three API roles:
alter default privileges in schema public grant all on tables to postgres, anon, authenticated, service_role;
The script sets this for tables created by postgres and, in separate lines, for tables created by supabase_admin, so either way a new table gets it. Supabase’s guide adds that tables created in the SQL editor start without RLS, while the dashboard enables it by default. A table public.profiles created that way, without any grant of our own, came out with anon=arwdDxtm: select, insert, update, delete, truncate, references, trigger and maintain. With RLS off, every request ran the same as anon, as user A and as user B:
| Statement | RLS off | RLS on, no policy | RLS on, policy user_id = auth.uid() |
|---|---|---|---|
select * | 4 rows | 0 rows | anon 0 rows; A and B 2 rows each |
insert | INSERT 0 1 | new row violates row-level security policy | — |
update (no where) | UPDATE 4 | UPDATE 0 | — |
delete (no where) | DELETE 4 | DELETE 0 | — |
truncate | table emptied | table emptied | table emptied, by anon |
A dash is a case we did not run. Every request ran in a transaction that we rolled back, so each started from the same four rows.
anon do to a table in public with Supabase's default privileges?Is row-level security enabled on the table?
- NoRead, update and delete every row, and insert anyPolicies on the table, if any, are ignored. Lint 0013 reports it.
- Yes
Does a policy apply to
anonfor this command?- No0 rows, and inserts failDefault deny.
- YesThe rows the policy allowsFor row commands; see the note on
TRUNCATE.
In every branch, anon still holds TRUNCATE, which row-level security does not check, and in our run emptied the table. No lint reports it.
The documentation is not consistent about these grants. The RLS guide says “a new table in public starts with every privilege already granted to all three roles”, and the API guide: “On existing projects, tables created in public receive SELECT, INSERT, UPDATE, and DELETE privileges for anon, authenticated, and service_role by default.” It also says Supabase is changing the platform default “to revoke these automatic grants so that exposure becomes opt-in”. The setup script we followed grants all, which includes TRUNCATE. Whether a project created today has these grants, we did not check. You can: select has_table_privilege('anon', 'public.profiles', 'TRUNCATE'); returned t here.
Why does RLS not stop TRUNCATE?
Because TRUNCATE does not look at rows. The PostgreSQL documentation on row security: “Operations that apply to the whole table, such as TRUNCATE and REFERENCES, are not subject to row security.” With RLS on and the policy in place, anon saw 0 rows and still ran TRUNCATE TABLE, and afterwards the table had 0 rows. We measured the same rule outside Supabase in who bypasses row-level security.
We ran SQL as the anon role, the role PostgREST switches to for a request with the public key. We did not test whether any request through the Data API can send a TRUNCATE. What the privilege means in practice depends on whether anything runs SQL as anon or authenticated that you did not write. Removing it takes two statements, run as postgres:
revoke truncate on all tables in schema public from anon, authenticated;
alter default privileges for role postgres in schema public
revoke truncate on tables from anon, authenticated;
The second covers tables postgres creates later; the defaults for supabase_admin can only be changed by that role. Check the result with select defaclacl from pg_default_acl where defaclnamespace = 'public'::regnamespace and defaclobjtype = 'r';. We did not run these two statements. Code of your own that truncates as authenticated stops working after the first.
Does a policy without RLS enabled do anything?
No. We created own_rows with using (user_id = auth.uid()) and left RLS off. anon and user B both read all four rows, A’s included, updated four and deleted four. A policy is stored, but PostgreSQL only applies it once row-level security is enabled on the table. The linter has a separate warning for this case, “Policy Exists RLS Disabled”.
Which linter warning catches what?
We loaded the lint queries from supabase/splinter (commit fccca4b) into the same database and ran them in each state:
State of public.profiles | Lint that reported it |
|---|---|
| RLS off | 0013 “RLS Disabled in Public”, ERROR: Table \`public.profiles\` is public, but RLS has not been enabled. |
| RLS off, policy present | 0013, and 0007 “Policy Exists RLS Disabled”: … has RLS policies but RLS is not enabled on the table. Policies include {own_rows}. |
| RLS on, no policy | 0008 “RLS Enabled No Policy”, INFO |
| RLS on, policy present | none, while anon could still truncate |
a view over the table, owned by postgres | 0010 “Security Definer View”; anon read all four rows through it |
The lint messages are as our output showed them, with a backslash before each backtick, as in the lint source. Lint 0013 checks whether anon or authenticated has SELECT on a regular table without RLS in an exposed schema. None of the four lints looks at TRUNCATE. The view case is the same one as in Supabase RLS policy not working: with security_invoker = on, anon read 0 rows, user B their own 2, and lint 0010 went quiet.
Is a table outside public safe?
Only as long as the schema stays unexposed and ungranted. A table private.secrets without RLS and without grants gave anon permission denied for schema private. After grant usage on schema private, permission denied for table secrets. After a grant select as well, anon read both rows. Supabase’s API serves only exposed schemas: “By default, your database has a public schema which is automatically exposed on data APIs”, and lint 0013 found the private table only after we set pgrst.db_schemas, the setting the lint reads for exposed schemas, to include private.
What to do
Enable row-level security on every table in an exposed schema, and add policies for the commands each role needs; the patterns are in Supabase RLS policy examples, tested. Revoke TRUNCATE from anon and authenticated, because no policy covers it. And check the privileges, not just the linter: a clean linter run left anon with a privilege that, in SQL, empties the table.
Reproduce it
setup.sql creates Supabase’s roles and default privileges as in the setup script, and the tables. run.sql runs every case as anon, A and B, and the lint queries. run.sh does both in a throwaway container.
What we did not measure
A live Supabase project or its Data API; we ran the SQL in psql under the roles the API uses. Whether a project created today still gets these default privileges. And the exact attributes of the postgres role on Supabase’s images; we created it as Supabase’s migration demotes it.
Sources: Supabase, Row Level Security, Database Advisors and Using custom schemas, read on 19 October 2026; supabase/postgres at commit d8666db and supabase/splinter at commit fccca4b, read on 19 October 2026; PostgreSQL 17 documentation, Row Security Policies, read on 19 October 2026. All results are our own, on PostgreSQL 17.11 in a throwaway container, on 19 October 2026.