LEAKCADE

Enable RLS when creating tables in Supabase, not later

Updated 8 min read

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.

Why a new table starts openA table is created without RLS. The anon role already has grants on it. A stranger with the public key reads every row. Turning RLS on closes the path.CREATEcreatetable, noRLSGRANTSanon hasevery grantPUBLICcalls API,public keyLEAKreads andwrites allrowsRLSdefault-denyblocks itCREATEcreate table, no RLSGRANTSanon has every grantPUBLICcalls API, public keyLEAKreads and writes all rowsRLSdefault-deny blocks it
No policy and RLS on means default-deny.

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:

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.

BEFORETable now, security "later"
-- 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
AFTERTable, RLS, grants and policies together
-- 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);
One file means there is never a commit where the table exists unprotected.

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.

  1. Create a migration file

    Run supabase migration new create_notes_table. This makes a timestamped file under supabase/migrations/. Don't build the table by clicking in the dashboard, or nothing records the RLS step.

  2. Write create table and RLS together

    Put create table, alter table ... enable row level security and the grants in the same file. Never commit the first without the second.

  3. Revoke default grants

    Run revoke all on the table from anon and authenticated, then grant back only the operations each role needs. A missing grant fails with 42501 before any policy runs.

  4. Add one policy per operation

    Write separate select, insert, update and delete policies with to authenticated and an auth.uid() check. Skip an operation if the app never needs it. No policy means that operation is denied.

  5. Apply and test straight away

    Run supabase db reset locally, then the test file below, then supabase db advisors. Push with supabase db push only 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.

FIX PROMPT
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.

HabitRiskySafe
When RLS is enabledIn a later ticketSame migration as create table
GrantsLeft at the defaultsRevoked, then granted back narrowly
Policy for anonusing (true) to make it workNone, or public data only
How the table is madeClicking in the dashboardA migration file in git
Verified byLooking at the appsupabase 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

  1. Supabase docs: Row Level Security
  2. Supabase docs: Database Advisors
  3. Supabase lint 0013: RLS disabled in public
  4. PostgreSQL docs: Row Security Policies
  5. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.