// blog / we-audited-fizzgig-with-fizzgig

We audited Fizzgig with Fizzgig — here's what our RLS checker found in our own schema

08 may 2026·by Lewis Howard·10 min read
#rls#supabase#vibe-coding#dogfood#security

we audited fizzgig with fizzgig — here's what our rls checker found in our own schema

Yesterday we shipped Fizzgig — 22 MCP-callable audit tools that plug into Cursor, Claude Code, and Windsurf so your AI editor can sanity-check the code it just wrote. Pre-deploy ritual stuff: secret leaks, RLS misconfigurations, broken links, GDPR gaps, the lot.

The first thing we did after shipping was point Fizzgig at itself.

We ran launch-checklist — the combo that fans out to all 22 tools in parallel — against our own marketing site, our own Cloudflare Workers, our own Supabase schema, our own everything. 79 findings came back. Most were noise (more on that later). But our own rls-checker found four real authorisation bugs in our own row-level-security policies that would have let signed-in users re-assign ownership of their own rows to other users.

We fixed them in one migration. This is the story of those bugs, why they're the canonical "AI-coded RLS" mistake, and why static auditing before you push a migration is the move.


the bug class — using vs with check

Quick refresher for the not-deep-in-Postgres crowd.

Row-level security in Postgres lets you write policies that gate row access per-user. In Supabase, this is how you make sure user A can read their own profile but not user B's. It's the entire authorisation model when you're using Supabase's anon key from the client.

A policy looks like this:

create policy "users can view own profile"
  on public.profiles for select
  using (auth.uid() = id);

using is the predicate that gets evaluated against existing rows. If the policy is for SELECT or DELETE, that's enough — the row already exists, you check whether the user can see/destroy it.

But for INSERT, UPDATE, and FOR ALL (which is INSERT + UPDATE + DELETE + SELECT combined), there's a second predicate: with check. This one runs against the new row — the row as it would look after the operation.

Why two predicates? Because using answers "can the user see this row to operate on it?" while with check answers "is the user allowed to create the result?" These are different questions.

Here's the attack: imagine an UPDATE policy on profiles with only using (auth.uid() = id). The policy says "user can update their own profile." Sounds right. Looks right. AI editors get this exactly right when they generate the SQL — the test passes, the migration runs, you ship.

But there's no with check. So when user A runs:

update profiles set id = '<user-b-uuid>' where id = '<user-a-uuid>';

…the policy checks using (auth.uid() = id) against the old row (user A's row, where id = user A's uuid — true, allowed) and lets the update proceed. The new row now has user B's id. User A has just handed their profile to user B — or impersonated them, depending on which way you read it.

The fix is one line:

create policy "users can update own profile"
  on public.profiles for update
  using (auth.uid() = id)
  with check (auth.uid() = id);  -- <-- this

Now both the old row (using) and the new row (with check) must have auth.uid() = id. The reassignment attack is closed.

This is the canonical Supabase RLS bug. Every developer who's been deep in RLS for more than a year has seen it. Most AI editors write policies without with check because the typical example pattern they're trained on is the SELECT case (where it isn't needed) and they pattern-match into UPDATE without thinking. The result looks like a working policy. The test (I can update my own profile) passes. The exploit doesn't surface unless someone actively tries it.


the four bugs in our own schema

Our rls-checker returned 13 findings on the Fizzgig migrations. Four of them were this exact bug, in four different tables.

bug 1 — profiles UPDATE without WITH CHECK

create policy "users can update own profile"
  on public.profiles for update
  using (auth.uid() = id);

Exposure: a user could re-assign their own profile row to another user_id. They'd lose the row (since the policy filter would no longer match), but the other user gains it — including any privileges, any linked records, any history.

bug 2 — api_keys UPDATE without WITH CHECK

create policy "users can revoke own keys"
  on public.api_keys for update
  using (auth.uid() = user_id);

Exposure: a user could change user_id on one of their own API keys to another user's id. Subtle but real — it'd let them launder API key ownership, or transfer access to a key's audit history.

bug 3 — tools UPDATE without WITH CHECK (with extra spice)

create policy "authors can update own draft tools"
  on public.tools for update
  using (
    auth.uid() = author_id and status in ('draft', 'review')
  );

This one's worse. The author is gated to drafts and reviews — but without with check, an author could update their draft and also flip the status to 'active' in the same statement. The using clause checks the OLD status (still 'draft'), the policy passes, the update goes through, and now the tool is 'active' without going through review.

It's also a re-assignment vector: an author could change author_id to anyone else's, transferring authorship.

bug 4 — projects ALL without WITH CHECK

create policy "users manage own projects"
  on public.projects for all
  using (auth.uid() = user_id);

for all is INSERT + UPDATE + DELETE + SELECT in one. Without with check, INSERT has no constraint on the new row at all — a user could insert a project with any user_id, including someone else's. The same row-reassignment vector exists for UPDATE.


the fix — one migration, no DROP / RECREATE

PostgreSQL's alter policy lets you add with check to an existing policy in place. No need to drop and recreate.

We shipped this as migration 034:

alter policy "users can update own profile" on public.profiles
  with check (auth.uid() = id);

alter policy "users can revoke own keys" on public.api_keys
  with check (auth.uid() = user_id);

alter policy "authors can update own draft tools" on public.tools
  with check (
    auth.uid() = author_id and status in ('draft', 'review')
  );

alter policy "users manage own projects" on public.projects
  with check (auth.uid() = user_id);

Audit-log row id from the rls-checker run that found these: 3ad319cd-42fb-4291-8c03-4c51e32c2630. Fix shipped in commit af95469. End-to-end took about 30 minutes from "tool surfaced the finding" to "fix in production."


the other 9 findings — why static analysis isn't enough by itself

rls-checker returned 13 findings. The 4 above were real bugs. The other 9?

  • USING (true) policies on tool_reviews and reserved_slugs. These are public-by-design tables — reviews are public, slug lookup is public. USING (true) is the correct policy. The tool flagged them as critical because that's what USING (true) looks like in the abstract. The right call is to downgrade them.
  • SECURITY DEFINER functions in our fizzgig_vault schema and the handle_new_user Supabase trigger. These bypass RLS by design — vault decryption needs to skip RLS to work, and handle_new_user is the canonical Supabase signup-trigger pattern. They're fine. The tool flags them because some SECURITY DEFINER functions are bugs (when they're missing internal auth checks), but ours are GRANT-protected.
  • 1× false-positive on a string literal inside another tool's input_schema SQL — the regex matched "select * from pg_policies" as if it were a CREATE POLICY statement. That's a bug in the tool's pattern; we'll tighten it.

This is where static security tools usually fail: they fire-hose findings, the user gets fatigued, and the real bugs drown.

We built rls-checker to handle this with two mechanisms: ai_context.downgrade_if is per-finding guidance for the AI consumer (e.g. "if this table has no user_id / owner_id / email columns, downgrade — it's reference data"), and synthesis_hint at the top level tells the AI to apply that triage before presenting findings.

When Claude Code or Cursor consumes our output, the AI sees those hints and does the triage in real-time. By the time the user reads the synthesis, "the 2 USING(true) findings on tool_reviews and reserved_slugs are intentional — both tables are public-by-design with no per-user data" is already in the response.

Static-analysis-with-AI-judgment turns out to be massively better than either alone. Static catches every potential issue. AI weighs the context. The user gets a credible assessment, not a 13-line list of "fix this maybe."


why this is the canonical AI-coded RLS bug

Three reasons:

1. The bug doesn't surface in a happy-path test. When you write "user A can update their profile" as a test, it works. The exploit requires adversarial input — user A trying to change their own user_id. Your AI editor doesn't write tests for that. You don't write tests for that. The test suite is green.

2. The fix is one line, but it's not where the AI looks. AI editors generate UPDATE policies by patterning off SELECT policies. The using clause is identical between the two. The with check is what's missing — and AI editors will write it when explicitly prompted, but they don't reach for it on their own.

3. The blast radius is invisible until exploited. Unlike a SQL injection (which usually surfaces in errors), an RLS bug is silent. The bad UPDATE succeeds. The user's row vanishes from their own queries (their auth.uid() no longer matches). They might not even notice for days.

If you're a vibe coder using Cursor or Claude Code on a Supabase project, you've almost certainly written a few of these. They're not your fault — they're a bug class, the kind that needs a tool to catch because the human reviewer's eye glazes past them.

That's rls-checker's entire job.


try it on your own project — rls-checker is in the free tier

Fizzgig is live at https://fizzgig.ai. Three tools are free, forever, and rls-checker is one of them (secret-leak-finder and dep-audit round out the free trio).

Setup takes about 30 seconds:

  1. Sign up for a free Fizzgig account
  2. Get your MCP URL from the dashboard
  3. Paste it into Cursor / Claude Code / Windsurf settings
  4. In your AI editor: "Audit my Supabase migrations for RLS issues"

The AI calls fizzgig__rls_checker, sends your migration SQL, and surfaces any of the 7 RLS misconfiguration patterns we track. using (true) policies, missing with check, auth.role() vs auth.uid() confusion, SECURITY DEFINER without internal auth checks, and three more.

Free tier. No credit card. 1000 calls per month — enough to run on every migration push for a year.


what's coming next

This is the first in a series of dogfood posts. The full Fizzgig audit on Fizzgig found bugs across a few different surfaces — dependency-config issues, missing with check on the policies above, and a class of self-bites where some of our own tools matched their own pattern definitions when we fed them the workers source. We'll publish them one at a time as they get fixed.

Next up: the dependency-pin discipline most vibe coders skip. Why caret-on-zero-dot ranges in your package.json will silently break your CI six months from now, and why dep-audit was right to flag two of ours.

If you want to be notified when the next one ships — there's a waitlist signup at https://fizzgig.ai. (No marketing emails. Just diff lines and the occasional growl.)