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
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_codefor anyfamilia_idand 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
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_admintotrueand 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;
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;
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
anonafter the patch → the promo function returns permission denied; the member update can't touchis_admin. -
Wrapped destructive checks in
begin … rollbackso 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:
-
security definer+grant execute to anonis a combo, not two settings. A definer function bypasses RLS, so its grant list is your security boundary. Audit them together. -
for allpolicies are a smell. Split by intent (select/insert/update/delete) so each gets the right clause. -
usingis notwith check. If a policy can write and it has nowith check, it can write things you didn't mean. -
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. 🔨
Top comments (0)