TLDR: an internal digital asset manager had a blanket Row-Level Security policy with with check(true) covering every table — including memberships, the table that decides who's an admin. Any authenticated user could fire one SQL statement and promote themselves. The fix was two lines: remove memberships from the blanket loop, add a SELECT-only policy. No write policy = no write access. Done.

The Audit That Found It

I was running a full security sweep — my internal security-officer audit pass — across an ecommerce business's entire fleet of apps on June 16.

Eleven repos. Five of them sharing a single Supabase project. One service-role key with BYPASSRLS that any one of those apps could leak, and suddenly you'd have full unrestricted access to every table in the database.

The digital asset manager (the internal file library for all brand assets) was first on the list. And it had Finding #1 before I even finished reading the migration.

What the Blanket Policy Actually Said

The 0001_init.sql migration had a clever little DO $$ block that looped over every table and applied a blanket policy:

foreach t in array array[
  'memberships','folders','assets','asset_versions','asset_metadata',
  'asset_renditions','tags','asset_tags','collections','collection_items',
  'share_links','ingest_jobs',...
] loop
  execute format('create policy ... using (true) with check (true)');
end loop;

using(true) — any authenticated user can read any row.

with check(true) — any authenticated user can write any row.

For folders and assets and tags? Fine. That's v1 internal tooling, trusted team, accepted risk.

For memberships?

That's the table that maps auth.users → member_role — admin, editor, viewer.

With with check(true) on memberships, any logged-in user could run:

update memberships set role = 'admin' where user_id = auth.uid();

One call. Full escalation. Supabase would happily comply.

Why It Wasn't Obvious

I'd looked at this setup before and thought it was fine.

That's the trap with blanket RLS. Every table shows RLS enabled ✓, every table has a policy ✓. The linter doesn't complain. The Supabase dashboard looks clean.

But with check(true) means something radically different on a table that controls access levels versus a table that stores file metadata.

The tables weren't equal. I'd treated them like they were.

The Two-Line Fix

The migration carved memberships out of the loop entirely and gave it its own explicit policy:

alter table memberships enable row level security;

create policy memberships_authenticated_select_own on memberships
  for select to authenticated
  using (user_id = (select auth.uid()));

That's it. One SELECT policy. No INSERT, no UPDATE, no DELETE policy for authenticated users.

In Postgres/Supabase, absence of a write policy = write denied. You don't need an explicit DENY — you just don't grant it. Authenticated users can now read their own row to know their own role. Nothing else.

Role assignment still works because the service role has BYPASSRLS — admin bootstrapping happens there, not through the regular auth path.

The other 15 tables kept the blanket policy, but it's now flagged explicitly in the migration as ACCEPTED-RISK (v1, PENDING SIGN-OFF) with a phase-7 TODO to refine to role-scoped policies. At least now it's a conscious decision, not a silent assumption.

Why This Matters

Privilege tables are a different category. Full stop.

Any table that answers the question "what can this user do?" needs its own policy, written by hand, with each operation spelled out explicitly. You can't bundle it in with your content tables and call it done.

The blanket pattern is genuinely useful for v1 internal tools — fast, readable, honest about the tradeoff. But the moment a memberships, roles, or permissions table ends up in that loop, you've handed every user the keys.

The thing that sticks with me: this wasn't a mistake I made recklessly. I had RLS on every table. I was thinking about auth. I just wasn't thinking about which tables control auth itself.

That's the lesson I'm carrying forward: blanket policies for content, fine-grained for anything that touches access control. Not because the linter will catch it — it won't. Because you have to.

P.S. This was Finding #1 from my internal security-officer audit pass on 2026-06-16 in the digital asset manager. The fact that it was first is not a coincidence — self-promotion vectors are the first thing you look for, because if that door is open, everything else is secondary.