Domdhi logoDOMDHI.OS
TECH

Supabase RLS: Security That Doesn't Suck

An audit of my own database found four criticals. Every single one was a policy written back when 'authenticated' meant me, and never revisited after I opened public signup. The policies didn't rot -- the trust model moved underneath them.

DDominic BacaSOLO ENGINEER · DOMDHI.OS2026.10.205 MIN READ
TECH// FIG.01

I ran a security audit against my own database. It came back with four CRITICALs.

Not one of them was exotic. Not one was a clever exploit chain someone had to reverse-engineer. All four were policies I wrote myself, that were correct when I wrote them, and became dangerous the day I changed something else entirely.

That's the actual lesson, and it took an audit for me to see it.

The Failure Mode Nobody Warns You About

Here's the sentence that explains all four findings:

Every policy encodes an assumption about who authenticated means. When that assumption changes, the policy doesn't.

When I started, authenticated meant me. One user. My own app, my own data. So a policy like this was completely reasonable:

CREATE POLICY "authenticated_all" ON some_table
  FOR ALL TO authenticated
  USING (true) WITH CHECK (true);

Every authenticated user can read and write everything. There was one authenticated user. It was me. Fine.

Then I opened public signup.

Nothing about that policy changed. Nothing about that table changed. No code was deployed. But in the instant signup went live, USING (true) stopped meaning "Dom can see his own stuff" and started meaning "anyone who can fill in an email form can read and write this table."

The vulnerability wasn't introduced by a commit. It was introduced by a product decision three layers away, and there is no linter, typecheck, or test that catches that.

The Four Shapes To Grep For

Once I understood the pattern I stopped reading policies one at a time and started grepping for shapes. These four cover most of it.

1. USING (true) on anything with user data.
This is the big one. It's not always wrong — a genuinely public table wants it. But it is always worth justifying in a comment, because it reads as "no filter" and that's exactly what it is.

2. A missing TO clause.

CREATE POLICY "users_read_own" ON newsletter_subscribers
  FOR SELECT USING (true);

That policy is named users_read_own. It does not read own. With no TO clause it applies to every role including anon, and the predicate is true. The name describes the intent; the SQL describes reality; they had drifted years apart. Never trust the policy name. I had a policy whose name was an outright lie and I'd read past it a dozen times.

3. An UPDATE scoped to the row but not the columns.
This was the worst one. A policy let users update their own profiles row — correct! — but UPDATE in Postgres is granted per table unless you scope it per column. So "update your own row" included updating your own role column. One PATCH request and a brand-new signup is a platform admin.

Row-level security is in the name. It is not column-level security. If an authorization value lives in a row the user can write, you need a column grant, not just a policy:

REVOKE UPDATE ON public.profiles FROM authenticated;
GRANT UPDATE (display_name, bio, avatar_url, timezone)
  ON public.profiles TO authenticated;

4. An extra permissive policy that quietly defeats a correct one.
Multiple PERMISSIVE policies on the same table and command are OR'd together. So a correct "Owner reads leads" policy sitting next to a sloppy FOR SELECT TO authenticated USING (true) doesn't give you defense in depth. It gives you the weakest of the two, every time. Adding a policy can only ever widen access, never narrow it.

The Part That Actually Bit Me

I want to be blunt about the mistake underneath the mistakes, because it's the one that generalizes.

I thought my route guards were a second layer.

They aren't. PostgREST doesn't go through your routes. Supabase publishes a REST API over your tables, and the anon key ships to the browser in every page load — it is a public string by design. Anyone with that key can hit /rest/v1/<table> directly. Your loader, your requireUser(), your module gating, your beautiful requireModuleAccess() — none of it is in the path.

So the honest security model is:

  • RLS is the only layer. Not the last layer. The only one.
  • Route guards are UX. They stop people from seeing a broken page. They stop nothing else.

I had genuinely internalized the opposite, and it shaped how carefully I wrote policies for two years.

Ground Truth Is pg_policies, Not Your Migrations

The other thing the audit changed permanently: I stopped believing my own migration files.

Migrations drop and recreate policies over time. A policy edited in the dashboard at 1am never appears in a migration at all. Replaying your migration history tells you what should be true, which is a different question from what is true.

SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check
FROM pg_policies WHERE schemaname = 'public'
ORDER BY tablename, policyname;

That's the only answer that counts. Same for column grants:

SELECT table_name, column_name, privilege_type
FROM information_schema.column_privileges
WHERE grantee = 'authenticated' AND table_name = 'profiles';

I verified every finding against live production before writing a single fix, and it was not wasted diligence — verification changed the shape of five findings and surfaced three defects the audit hadn't seen at all. One finding cited the wrong file entirely.

Audit reports are leads, not facts. Check them.

The Rule I Actually Run Now

When a trust boundary moves — public signup, a new role, an external integration, an API key — re-read every policy, not just the ones near the change.

That's the whole discipline. The policies are fine. The policies were always fine. What changes is the meaning of the words inside them, and nothing in your toolchain will tell you the meaning moved.

grep -rn "USING (true)" supabase/migrations/
grep -rnL " TO " supabase/migrations/*policy*

Two greps. Run them the day you change who can sign up.

All four criticals were closed in a single hotfix and verified live against pg_policies — the fix took an afternoon. Finding them took an audit I only ran because something unrelated made me nervous.

That ratio is the uncomfortable part. Not the fix. The finding.

GET_TRANSMISSIONS

Raw signal on finance, freedom, fitness, and tech. No spam. Unsubscribe anytime.

D
Dominic Baca

One engineer building Domdhi.OS in public — money, freedom, and the occasional 2am migration. Every number live, every commit public.

FOLLOW THE LOGS →
// NEXT IN THE LOGS