This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

Every app has its rockstar bug. Mine didn't crash, didn't throw, didn't page me at 3am. It just sat there in production — behind a paying customer's login — waiting for anyone with a browser to walk on stage and take a bow.

Then I decided to attack my own app with nothing but the API key I ship to every visitor. Two exploits took a bow that day. Here's how I caught them.

The setup: the key you hand to strangers

I run Zingui, a small family-finance app, on Next.js + Supabase. Real users, real paying subscriptions. Like every Supabase app, the browser ships with a public anon key — that's by design. Row Level Security (RLS) and function grants are supposed to be the wall that makes a public key safe.

The uncomfortable question I finally asked: if I open the network tab, copy that anon key, and talk to the database directly — not through my UI — what can I do?

I opened a plain REST client, pointed it at my own PostgREST endpoint with the anon key, and started poking. No login. No session. Just the key everyone already has.

Bug #1: the stranger who could cancel your paid plan

My app has a "redeem promo code" action and a "switch to family plan" action. Both were Postgres functions. Both looked like this (simplified):

-- The footgun
create function aplicar_promo_code(p_familia uuid, p_code text)
returns void
language plpgsql
security definer          -- runs as the function OWNER, bypassing RLS
as $$
begin
  update assinaturas
     set status = 'trial',                    -- <-- always resets to trial
         expira_em = now() + interval '30 days'
   where familia_id = p_familia;              -- <-- no "is this MY family?" check
end;
$$;

grant execute on function aplicar_promo_code(uuid, text)
  to anon, authenticated;                      -- <-- anon can call it

Enter fullscreen mode Exit fullscreen mode

Three words did the damage: security definer. That flag makes the function run as its owner and bypass RLS entirely. Combined with grant execute ... to anon and no ownership check, this meant:

  • Anyone, with no account at all, could call aplicar_promo_code for any familia_id and change that family's subscription.
  • Worse — the function always set status = 'trial'. So firing it at a paying customer didn't upgrade them. It downgraded them. A stranger could quietly knock a paying subscription back down to a trial clock.

I reproduced it live against my own production project with the anon key and zero authentication. It worked. That's a confirmed, exploitable-from-the-internet billing bug in an app with paying users. Cue the drums.

Bug #2: the member who could crown themselves

The second one lived in an RLS policy on the membros (household members) table:

-- The footgun
create policy membros_all on membros
  for all                                     -- SELECT + INSERT + UPDATE + DELETE
  to authenticated
  using ( familia_id = minha_familia() );     -- USING only, no WITH CHECK, no column limit

Enter fullscreen mode Exit fullscreen mode

for all with only a using clause and no with check is a classic Supabase trap. using decides which rows you can see and touch; with check decides what you're allowed to write. With with check missing, any logged-in member — talking straight to PostgREST, bypassing my UI — could:

  • flip their own is_admin to true and become a household manager,
  • lock the actual owner out,
  • edit columns the app never exposed.

A regular member could crown themselves king of a household they were only invited to.

The fix: take the stage back

For the billing functions:

-- Only the server may call these now
revoke execute on function aplicar_promo_code(uuid, text) from anon, authenticated;
grant  execute on function aplicar_promo_code(uuid, text) to service_role;

Enter fullscreen mode Exit fullscreen mode

The endpoints now resolve the family from the authenticated session on the server and call the function via the service role — the client can't name an arbitrary familia_id anymore. I also fixed the function itself to never downgrade a valid paid subscription (base the new expiry on max(now(), expira_em) instead of blindly resetting to trial), and pinned search_path to close the SECURITY DEFINER search-path hole.

For the member policy, I split the one greedy for all into intent-specific policies and added column-level grants:

create policy membros_select on membros
  for select to authenticated
  using ( familia_id = minha_familia() );

create policy membros_update_self on membros
  for update to authenticated
  using  ( id = meu_membro_id() )
  with check ( id = meu_membro_id() );        -- <-- the guard that was missing

-- authenticated can only write the harmless columns; is_admin is not one of them
grant update (nome, avatar_url, forma_pagamento_preferida, onboarding_visto)
  on membros to authenticated;

Enter fullscreen mode Exit fullscreen mode

Promotions to manager now go through a server path that checks who's asking. is_admin is simply not grantable to authenticated anymore.

Before / after, proven in prod

I don't trust a fix I haven't tried to break again. So I verified on the real production database:

  • Re-ran both exploits as anon after the patch → the promo function returns permission denied; the member update can't touch is_admin.
  • Wrapped destructive checks in begin … rollback so I could prove behavior against live data without persisting anything — e.g. confirming a paid-and-valid subscription is left untouched when the promo path runs.
  • Added a regression test for the intra-family privilege escalation so a future migration can't quietly reopen it.

Before: a public key was a loaded gun. After: the anon key can read what it should and nothing it shouldn't.

What I actually learned

The bugs were loud in impact but silent in the codebase — no error, no log, no stack trace. Four things I now treat as non-negotiable on any Supabase project:

  1. security definer + grant execute to anon is a combo, not two settings. A definer function bypasses RLS, so its grant list is your security boundary. Audit them together.
  2. for all policies are a smell. Split by intent (select / insert / update / delete) so each gets the right clause.
  3. using is not with check. If a policy can write and it has no with check, it can write things you didn't mean.
  4. Grant columns, not tables. grant update (col, col) is the difference between "edit your avatar" and "make yourself admin."

Catching these two live was the thing that made me go RLS-first on every project since. If you want to see a cross-tenant leak instead of just reading about one, I put together a live demo against real Postgres — same table, one policy leaks, one blocks, and you can re-run the request as the other tenant to prove the row was there all along. The free MIT tools next to it (airlock-rls, airlock-migrate) fail your build when a table ships with RLS off — not a silver bullet for every footgun above, but the cheapest way to stop the most common one. Turns out the best way to stop giving your rockstar bugs a stage is to stop building the stage in the first place.

Smash responsibly. 🔨