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 (3)
Great example of testing the effective database API rather than the UI. One PostgreSQL nuance is worth making explicit: when a policy has
USINGbut omitsWITH CHECK, PostgreSQL generally reuses theUSINGexpression as the write check. So the privilege escalation here is not simply “no WITH CHECK means unrestricted writes”; the family-level predicate still allows the member’s updated row, including anis_adminchange. Your column-level grant is therefore the decisive control for that field, while the narrower self-row predicate reduces which rows can be changed. I would turn the audit into CI by diffingpg_proc.prosecdef, function ACLs, pinnedsearch_path, table/column grants, RLS enable/force state, and policy expressions—then run attacker fixtures as anon and ordinary authenticated users after every migration.Point 3 in your list is the one I would sharpen, because the loose version of it produces false positives — I published one this morning and had to go back and correct it publicly.
When a policy has no
with check, Postgres does not leave the write unguarded. It applies theusingexpression to the new row as well. So the accurate rule is:Your
membroscase is a real vulnerability under that reading, not despite it:using (familia_id = minha_familia())does stop a member from moving a row into another household, because the new row still has to satisfy it. What it never touches isis_admin— so the escalation you found is exactly the column the predicate is silent about. Same conclusion, different reason, and the reason matters when you go looking for the next one: "missingwith check" is not the signal. "predicate that does not mention the column you care about" is.The two bugs generalise into a pair of catalog sweeps, and both are worth running on any project rather than only on the tables you already suspect.
Every definer function that anon or authenticated can reach — your bug #1, as a whole-schema question:
Every row with
anon_can_call = trueis a boundary, and a nullsearch_path_pinnedis the second half of the hole you already closed.Every FOR ALL policy and what it actually checks on write — your bug #2:
polcmd = '*'is FOR ALL. A nullwith_check_exprthere means theusing_exprcolumn beside it is your entire write rule — read it once against the table's column list and the gap is usually obvious.The part worth underlining for anyone skimming: neither of your bugs is visible to a linter. The advisor checks whether RLS is on and whether a policy exists. Bug #1 has no policy to inspect at all, and bug #2 has a policy that is present, enabled, and syntactically fine. Both need someone to read the predicate against the schema, which is why "RLS enabled everywhere, advisor green" keeps meaning less than people expect.
Good writeup — the
begin … rollbackdetail for testing against live data without persisting is the part I wish more people copied.Attacking your own app with a public API key is a classic wake-up call for Row Level Security policies in Supabase. It is wild how easily a missing auth check can expose an entire table if you forget to enable RLS by default. I actually had to rebuild our database schema after a similar oversight when I was first putting together our SaaS boilerplate. We now enforce strict RLS templates across all new tables in PubliFlow so developers do not accidentally ship an open database. Did you end up writing a custom script to audit your existing policies, or did you have to review them manually one by one?