DEV Community

Shipsealed
Shipsealed

Posted on

Two exploits, one public API key: the day I attacked my own Supabase app

Summer Bug Smash: Smash Stories 🐛🛹

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. 🔨

Top comments (3)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Great example of testing the effective database API rather than the UI. One PostgreSQL nuance is worth making explicit: when a policy has USING but omits WITH CHECK, PostgreSQL generally reuses the USING expression 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 an is_admin change. 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 diffing pg_proc.prosecdef, function ACLs, pinned search_path, table/column grants, RLS enable/force state, and policy expressions—then run attacker fixtures as anon and ordinary authenticated users after every migration.

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU

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 the using expression to the new row as well. So the accurate rule is:

The write is constrained by exactly the same predicate as visibility, and any column that predicate does not mention is unconstrained.

Your membros case 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 is is_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: "missing with 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:

select n.nspname,
       p.proname,
       pg_get_userbyid(p.proowner)                     as owner,
       has_function_privilege('anon', p.oid, 'EXECUTE')          as anon_can_call,
       has_function_privilege('authenticated', p.oid, 'EXECUTE') as auth_can_call,
       p.proconfig                                     as search_path_pinned
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef
  and n.nspname not in ('pg_catalog','information_schema','extensions',
                        'graphql','graphql_public','pgbouncer','realtime',
                        'storage','vault','auth','net','cron')
order by anon_can_call desc, auth_can_call desc, 1, 2;
Enter fullscreen mode Exit fullscreen mode

Every row with anon_can_call = true is a boundary, and a null search_path_pinned is the second half of the hole you already closed.

Every FOR ALL policy and what it actually checks on write — your bug #2:

select c.relname,
       p.polname,
       pg_get_expr(p.polqual, p.polrelid)      as using_expr,
       pg_get_expr(p.polwithcheck, p.polrelid) as with_check_expr
from pg_policy p
join pg_class c     on c.oid = p.polrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and p.polcmd = '*'
order by 1, 2;
Enter fullscreen mode Exit fullscreen mode

polcmd = '*' is FOR ALL. A null with_check_expr there means the using_expr column 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 … rollback detail for testing against live data without persisting is the part I wish more people copied.

Collapse
 
publiflow profile image
PubliFlow

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?