TLDR: schemaname='public' in a Postgres migration doesn't mean "this app's tables." On a shared database, it means every table from every tenant. We caught it before running. Barely.

The Setup

We were doing a full security hardening pass on an ecommerce business fleet — 11 apps, one afternoon, /cso audit from top to bottom.

Five of those apps share a single Supabase project. A KOL CRM, a supply chain tool, a webinar project manager, webinar dashboards, and an affiliate payout tracker — all talking to the same ~80 tables through the same full-privilege service-role key (a special credential that bypasses Postgres row-level security entirely).

One service-role credential leak in any one of those apps = full RLS bypass on all of them.

That's the blast radius we were tightening.

The Migration That Looked Fine

For the KOL CRM, we wrote 025_fix_pii_rls.sql — a migration to lock PII tables behind an is_allowed_user() function. Domain-gated. Airtight.

The migration ends with a DO $$ block. A sweep. And right above it, the comment reads: "scoped to public schema."

Sounds right. We're in the KOL CRM repo. The public schema is where its tables live.

Except… public in Postgres is not a per-app namespace.

It's the default schema name. And this database has five apps all writing into the same public.

What Would Have Happened

That DO $$ block's filter is schemaname='public' — which matches every table in every app on the shared DB.

Every open true / authenticated policy across the KOL CRM, supply chain tool, webinar project manager, dashboards, and affiliate payout tracker would have been re-gated onto the KOL CRM's is_allowed_user() function.

Supply-chain auth: broken. Webinar PM: broken. Affiliates: broken.

All of it, silently, mid-session, across tables we didn't even know were in scope.

How We Caught It (And What We Did Instead)

I had already mapped the blast radius for the shared DB — that map was the whole reason the CSO pass was possible in the first place.

When I went to apply the migration, I stopped and re-read the DO $$ predicate instead of trusting the comment.

A comment on a migration is not a constraint. The SQL is.

We ran the predicate as a read-only SELECT first — just to eyeball the actual rows it would touch. The list was not "just the KOL CRM's tables." It was everything.

We stood down from the sweep. The standing rule now: never run that migration's DO $$ block. The RLS was applied table-by-table instead.

There's also a sequencing lesson buried in here: the webinar project manager's migration 008 needs to run before any sweep like this is even attempted — 008 re-gates the webinar PM tables onto their own is_allowed_user() function, which pulls them out of the sweep's net. The order matters enormously on shared schemas.

Why This Stays With Me

The comment said "scoped to public." The intent was "this app only." But the code doesn't read intent — it reads the schema name.

And public in Postgres belongs to everyone.

On a shared database, the right preflight before any bulk DDL or policy sweep is dead simple: run the WHERE predicate as a SELECT, read the rows, confirm the scope. Don't trust comments. Don't trust names.

Read what the SQL actually touches.

That one SELECT probably saved us four broken apps and a very bad night.