Enable RLS when creating tables in Supabase, not later
Enable RLS when creating tables, in the same migration as the create table. A new table in an exposed schema is readable and writable by any role with a grant on it until row level security is on. AI agents often write the table first and "add security later". Later is how data leaks.
QUICK CHECKLIST
- Put create table and enable RLS in one migration
- Revoke default grants from anon and authenticated
- Write one policy per operation, never using (true) by reflex
- Run supabase db advisors after every migrate
- Add a pgTAP test file per table
- Reject any migration that has no RLS line
Why a new table is open until you enable RLS
Postgres checks two things before a client touches a table. Grants decide whether a role can run an operation at all. Policies decide which rows that operation reaches. CREATE TABLE sets up neither for you.
Postgres itself says that tables have no policies by default. If a user has privileges on the table, all rows are available for querying and updating. Supabase's docs add the part that hurts: on existing projects, a new table in public starts with every privilege already granted to anon, authenticated and service_role. Not every project grants these automatically, so check yours.
Put those together. Your publishable key sits in the browser by design, so the anon role is one fetch away from anyone.
How AI agents leave tables open
The pattern is boring, which is why it keeps shipping. The agent writes a migration that creates the table, wires up the UI, and the feature works. Nothing fails, so nobody looks again.
These are the habits to watch for:
- The table-only migration.
create tablewith noalter table ... enable row level securityanywhere in the file. - The "RLS later" ticket. A TODO comment or a follow-up task that never gets done.
- The checkbox policy. RLS is on, but the policy is
using (true)foranon, which makes every row public again. - The dashboard shortcut. The table is made in the Table Editor, so no migration records the RLS step at all.
If you want the policy-level bugs in more depth, see common Supabase RLS mistakes. This guide is only about the moment of creation.
What happens when you enable RLS with no policy
Once RLS is on, Postgres switches to default-deny. The Postgres docs say that if no policy exists for the table, a default-deny policy is used, so no rows are visible or can be modified. Supabase says the same thing in API terms: with RLS enabled, no data is accessible through the API with a publishable key until you create policies.
That is the safe failure mode. A forgotten policy gives you an empty result, not a leak. You notice, you fix it, nobody's data is exposed.
Two details are worth knowing. The table owner usually bypasses RLS, and so do superusers and roles with BYPASSRLS. The service_role key bypasses it too, so it must stay on the server. See why the service role key must never reach the browser.
The Security Advisor lint 0013_rls_disabled_in_public is the check that flags a public-schema table that has RLS off. You can run advisors from Studio, with supabase db advisors in the CLI, or through the MCP get_advisors tool.
Create table and enable RLS in one migration
The fix is a habit, not a tool. Every migration that creates a table in an exposed schema also turns on RLS, sets grants and writes policies, in the same file. That way there is no commit where the table exists unprotected.
-- 20250101000000_create_notes.sql create table public.notes ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users not null, body text ); -- TODO: add RLS
-- 20250101000000_create_notes.sql create table public.notes ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users not null, body text ); alter table public.notes enable row level security; revoke all on table public.notes from anon, authenticated; grant select, insert, update, delete on table public.notes to authenticated; create policy "Users read own notes" on public.notes for select to authenticated using ((select auth.uid()) = user_id); create policy "Users create own notes" on public.notes for insert to authenticated with check ((select auth.uid()) = user_id); create policy "Users update own notes" on public.notes for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); create policy "Users delete own notes" on public.notes for delete to authenticated using ((select auth.uid()) = user_id);
The grants matter as much as the RLS line. Supabase's own procedure is to enable RLS, revoke existing grants from both client roles, then grant back only what the role needs. For a table users manage themselves, that means authenticated only and nothing for anon.
Policies get one statement per operation. Postgres does not accept several operations in one for clause, and a for all policy hides which rule covers which action. The update policy needs both using and with check, plus a matching select policy, or updates will not work as expected. Our guides on insert policies and delete policies cover the traps in each one.
How to enable RLS when creating tables, step by step
Do this for every table in an exposed schema, every time. Use a migration file so the steps are recorded and reproducible.
Create a migration file
Run
supabase migration new create_notes_table. This makes a timestamped file undersupabase/migrations/. Don't build the table by clicking in the dashboard, or nothing records the RLS step.Write create table and RLS together
Put
create table,alter table ... enable row level securityand the grants in the same file. Never commit the first without the second.Revoke default grants
Run
revoke allon the table fromanonandauthenticated, thengrantback only the operations each role needs. A missing grant fails with42501before any policy runs.Add one policy per operation
Write separate
select,insert,updateanddeletepolicies withto authenticatedand anauth.uid()check. Skip an operation if the app never needs it. No policy means that operation is denied.Apply and test straight away
Run
supabase db resetlocally, then the test file below, thensupabase db advisors. Push withsupabase db pushonly when they pass.
Keep this order the same for every table. A habit that takes ten seconds beats an audit that takes a week.
Test it yourself
Run these only against your own project. A migration that looks right can still leave the table open, so check the result.
Check 1: the advisor. Run the linter from the CLI, or open Security Advisor in Studio and look for rls_disabled_in_public:
supabase db advisors
A clean run has no 0013 finding for your new table.
Check 2: ask the API as a stranger. Use your project URL and the publishable key from your own front end, with no user session:
curl "https://YOUR_PROJECT.supabase.co/rest/v1/notes?select=*" \ -H "apikey: YOUR_PUBLISHABLE_KEY" \ -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY"
If you get rows back for a table that should be private, RLS is off or a policy is too wide. If you get an empty array or a 42501 permission error, the table is closed to strangers. For a fuller walk-through of probing with different users, see how to test Supabase RLS.
Check 3: a pgTAP file per table. Supabase's own procedure ends with a test file under supabase/tests/ that asserts allow and deny for anon and authenticated. Create it with supabase test new notes_rls.test and run supabase test db:
begin; select plan(2); set local role anon; select throws_ok( $$ select * from notes $$, '42501', null, 'anon cannot read notes' ); select throws_ok( $$ delete from notes $$, '42501', null, 'anon cannot delete notes' ); select * from finish(); rollback;
This is a trimmed version. Add owner and non-owner cases for authenticated, and assert that a read returns the row it should, since an empty result can pass a weak test.
Fix prompt you can paste
Give this to Cursor or Claude Code to audit what is already there. It asks for file and line output so you can check each claim.
Audit every SQL migration in supabase/migrations/ and any schema SQL in this repo. For each CREATE TABLE in the public schema (or any schema exposed through the API): 1. Report the file and line, and whether the same migration or a later one has ALTER TABLE ... ENABLE ROW LEVEL SECURITY. List tables that never get it. 2. Report whether grants are revoked from anon and authenticated and granted back only for the operations the app needs. 3. List every policy on the table. Flag any that use using (true) or with check (true) for anon or authenticated unless the data is meant to be public, and say why you think it is or isn't. 4. Flag tables with RLS enabled and no policy (they deny everything, check that is intended). Then fix it: add a new migration that enables RLS, revokes default grants, and adds one policy per operation using (select auth.uid()) = user_id where the table has an owner column. Add supabase/tests/<table>_rls.test.sql per table with allow and deny assertions for anon and authenticated, and run supabase test db. Do not edit old migrations that are already applied. Explain every change you make.
Read the diff before you accept it. If the agent fixes a policy by loosening it to using (true), reject that and ask again.
Make it automatic
You can stop relying on memory. Supabase's docs point to event triggers as a way to enable RLS automatically on every new table, and to revoking default privileges so new tables don't start with open grants. Check the current docs for the exact setup for your project.
It is also worth adding a rule file for your AI tool: "every migration that creates a table must enable RLS and set grants in the same file." The Cursor and Claude Code security checklist shows where to put rules like that. For the bigger picture, the vibe coding security checklist lists the other defaults to lock down.
| Habit | Risky | Safe |
|---|---|---|
| When RLS is enabled | In a later ticket | Same migration as create table |
| Grants | Left at the defaults | Revoked, then granted back narrowly |
| Policy for anon | using (true) to make it work | None, or public data only |
| How the table is made | Clicking in the dashboard | A migration file in git |
| Verified by | Looking at the app | supabase test db and advisors |
Pick the right column every time you add a table.
If you want a second pair of eyes on a live project, Leakcade can scan it for open tables.
Further reading
- Supabase docs: Row Level Security
- Supabase docs: Database Advisors
- Supabase lint 0013: RLS disabled in public
- PostgreSQL docs: Row Security Policies
- Supabase docs: Local development and migrations
FAQ
Is a new Supabase table public by default?
It can be. On projects that still grant anon and authenticated automatically, a new table in public has every privilege granted, and with RLS off, those roles can read and write it. Check your project's default privileges and turn RLS on for every table.
How do I enable RLS when creating a table in a Supabase migration?
Put alter table public.your_table enable row level security; in the same migration file as the create table. Then revoke default grants and add one policy per operation before you push.
What does the rls_disabled_in_public warning mean?
It is Security Advisor lint 0013. It flags a table in the public schema that has RLS turned off, so anyone with API access and a matching grant can reach its rows. Enable RLS and add policies, unless the table is intentionally public.
What happens if RLS is on but I have no policy?
Postgres uses a default-deny policy, so no rows are visible and none can be changed through the API. That is the safe default. Add policies for the operations you want to allow.
How do I test that my RLS migration worked?
Run supabase db advisors to check for lint 0013, call the REST API with only the publishable key, and run a pgTAP test file with supabase test db that checks allow and deny cases for anon and authenticated.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms