← All posts
·6 min read·GuardLayer

Every Supabase table needs RLS — the one you forgot is public

SupabaseRLSPostgresMigrations

Here's the Supabase fact that surprises people: a brand-new table has Row Level Security turned off. Not "off until you add policies" — off entirely. Until you run ALTER TABLE ... ENABLE ROW LEVEL SECURITY, the table is open to the anon and authenticated roles, which means anyone holding your anon key (it's in your browser bundle, so: everyone) can select * from it.

So the question "do I need RLS on every table?" has a one-word answer for any table in the public schema that PostgREST can reach: yes. And the dangerous part isn't the table you forgot to think about — it's the table you did secure, sitting right next to one you added later and forgot. RLS is per-table. Locking down invoices does nothing for invoice_line_items.

This isn't hypothetical — CVE-2025-48757. In May 2025, researcher Matt Palmer found 303 endpoints across 170 production apps with Supabase tables readable by anyone holding the public anon key — emails, addresses, even API keys — purely because RLS was never turned on. That is exactly the one-table-you-forgot gap this post is about.

The setup that bites everyone

You write a careful first migration. You enable RLS, you scope a policy with auth.uid(), you feel good. Two weeks later you add a related table in a rush — and the muscle memory doesn't fire:

-- 0002_billing.sql
create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id),
  amount_cents int not null,
  created_at timestamptz default now()
);

alter table public.invoices enable row level security;

create policy "own invoices" on public.invoices
  for select using (auth.uid() = user_id);

-- added two weeks later, in a hurry
create table public.invoice_line_items (
  id uuid primary key default gen_random_uuid(),
  invoice_id uuid not null references public.invoices (id),
  description text,
  amount_cents int not null
);

invoices is locked down. invoice_line_items — which holds every line of every customer's billing — has no ENABLE ROW LEVEL SECURITY and no policy. It is public. This is exactly what GuardLayer flags on that migration; this is live engine output, not a mockup:

guardlayer scan · supabase/migrations/0002_billing.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/0002_billing.sql:15

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.

Notice the scanner doesn't complain about invoices. It tracks RLS per table and points only at the one that's actually exposed — the forgotten one.

Why "RLS off" is worse than it sounds

People assume an unprotected table is hard to reach. It isn't. PostgREST auto-generates a REST endpoint for every table in public, so an open table isn't "exposed if someone finds it" — it's a documented HTTP endpoint:

curl "https://<project>.supabase.co/rest/v1/invoice_line_items?select=*" \
  -H "apikey: <your-anon-key>"

The anon key ships in your client bundle by design — it's meant to be public, because RLS is supposed to be the thing standing between it and your data. When RLS is off, nothing is standing there. Every row comes back. No login, no policy, no friction.

Three things make this especially nasty:

  • It's silent. The table works perfectly. Your app reads and writes it. No error at migrate time, no warning at deploy. Nothing tells you it's open.
  • It's per-table, not per-project. Enabling RLS on ten tables and forgetting the eleventh leaves the eleventh fully exposed. There's no project-wide default that catches up.
  • Joins don't save you. Even if your UI only ever reads invoice_line_items through invoices, an attacker can hit the table's endpoint directly. A foreign key to a protected table inherits none of its protection.

The fix: enable RLS in the same migration, every time

Make ENABLE ROW LEVEL SECURITY the line that immediately follows every CREATE TABLE in public. Then add policies. The corrected migration:

create table public.invoice_line_items (
  id uuid primary key default gen_random_uuid(),
  invoice_id uuid not null references public.invoices (id),
  description text,
  amount_cents int not null
);

alter table public.invoice_line_items enable row level security;

-- A user can read line items only for invoices they own.
create policy "own line items" on public.invoice_line_items
  for select using (
    exists (
      select 1 from public.invoices i
      where i.id = invoice_line_items.invoice_id
        and i.user_id = (select auth.uid())
    )
  );

Two things worth saying out loud:

Enabling RLS with no policy denies everything. Once RLS is on and there are zero policies, every query through the anon/authenticated roles returns nothing — the safe default, fail closed. It's far better to ship a table that returns no rows than one that returns all of them. Add the policy next, but the ENABLE line alone already closes the hole. (The opposite trap — RLS on, but with a using (true) policy that lets everyone through — is its own failure mode worth knowing.)

RLS doesn't apply to the service_role key. The service role bypasses RLS entirely, which is fine for trusted server-side jobs — but it means your server code keeps working even when a table is wide open to the public. That's a big reason these holes go unnoticed: your own app, running as service_role, never trips over them.

One small detail in the policy above: (select auth.uid()) instead of a bare auth.uid(). Postgres evaluates the subquery once per statement (an InitPlan) rather than once per row, a real win when the predicate runs over a large table. It doesn't change the security behavior.

A 30-second self-check

Ask your database which public tables don't have RLS turned on:

select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Anything in that result set is currently readable through your anon key. For tables that have RLS on but no policies, this one's worth a look too — it finds tables that are locked down to deny-all but probably aren't usable yet:

select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and c.relrowsecurity = true
  and not exists (
    select 1 from pg_policy p where p.polrelid = c.oid
  );

Run both after every migration — or let a scanner do it on each push, so a forgotten table never reaches production in the first place.

FAQ

Do I really need RLS on every table? On every table in the public schema, yes — PostgREST exposes them all over HTTP, and the anon key reaches them. Tables in a private schema you never expose are a different story, but the safe rule is: if it's in public, enable RLS.

I enabled RLS but didn't add any policies. Am I safe? Yes, for exposure. With RLS on and no policies, the table denies all access through the anon/authenticated roles by default. You'll need policies to make it usable, but you're not leaking data in the meantime.

Supabase's dashboard warned me about this — isn't that enough? The dashboard linter flags it, but only when you look. It won't stop a migration applied via the CLI or CI from shipping. Catch it in code review or in your pipeline, before it's live.

Does enabling RLS slow down queries? Negligibly for well-written policies, especially when the predicate hits an indexed column like user_id and you wrap auth.uid() as (select auth.uid()). The cost of a leaked table is not negligible.

What about tables only my server touches? Your server using the service_role key bypasses RLS, so it keeps working regardless. But "only my server touches it" is an assumption about your code, not a constraint on the database. Enable RLS anyway — defense in depth costs you one line.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading