Row Level Security was on for every table in our Supabase project, and anyone holding our public anon key could still add 100,000 credits to any account with one HTTP call. RLS protects tables. It does not protect functions, and Postgres grants EXECUTE on every new function to PUBLIC by default. Supabase's Oct 30 change, which stops new tables being exposed to the Data API automatically, is written about tables. Its changelog does not describe EXECUTE on the functions you already have. If you wrote a SECURITY DEFINER function and never revoked anything, check it today.
This is what happened to us, the exact fix, and the test that proves the fix holds.
What we found
Venturehand runs on one shared credit pool. Four Postgres functions move it: spend_credits, spend_credits_once, refund_credits and refund_credits_once. All 4 were SECURITY DEFINER, which means they run with the owner's rights and skip RLS. All 4 took a p_user_id argument and never checked who was calling.
That design was fine as long as only our server called them. Every caller in our app used the service-role key from web/lib/credits.ts. Nobody had written a grant that let the browser call them.
Nobody had written a revoke either. Postgres documents the default plainly: for functions and procedures, the default privilege granted to PUBLIC is EXECUTE. Supabase's Data API exposes functions in the public schema at /rest/v1/rpc/<name>. So the four were callable by anon and by any signed-in founder, against any account.
We found it during a code review on 2026-10-06, not from an incident. On a local stack, one anon call to refund_credits with p_amount: 100000 took a test balance from 10 to 100,010.
Why nothing told us
Every check we had was green, and every one of them was checking something else.
- RLS was on. Our cross-user RLS probe already tested that founder B cannot read or write founder A's rows. It passed. It never called an RPC.
- The types were fine. The function signatures were correct and the app called them correctly.
- The app worked. Spending and refunds behaved exactly as designed, because the design was right. Only the grant was wrong.
- The dashboard showed RLS enabled. RLS status is a table property. A function has no RLS badge to look at.
This is the failure we keep writing about on this blog: a check that reports success about the thing it covers, while the hole sits next to it. Our earlier post on charging for work an agent does for you argues that a credit should be spent only after the work succeeds. That rule means nothing if the browser can call the function that moves the balance.
What Supabase's Oct 30 change does and does not do
Supabase's breaking change went live for all projects on 2026-10-30. New tables in the public schema are no longer exposed to the Data API without explicit grants. That closes the most common leak, a new table created with RLS off.
Read the changelog for what it leaves alone. Its grant examples cover table privileges and sequences. It says existing tables keep their current grants. It does not describe EXECUTE on existing functions. A function you created in July still has whatever grants it had in July. If that was the Postgres default, anon can still call it.
Do not read "Supabase made the Data API safer" as "my project is now safe". The change sets defaults for objects created from now on. Your exposure is in objects you already have.
The fix, step by step
These are the steps we took, in order. Each one is small.
1. Revoke from all three client roles, then grant back to the one that needs it
Our migration is 20261002120000_revoke_client_credit_rpcs.sql. For each function:
revoke all on function public.refund_credits(uuid, integer, jsonb) from public, anon, authenticated; grant execute on function public.refund_credits(uuid, integer, jsonb) to service_role;
Name public as well as anon and authenticated. Supabase's troubleshooting page on revoking execution of a Postgres function says the same: all function access is PUBLIC by default, and blocking anon takes 2 steps, a revoke from public and a revoke from the role.
Before writing this, we checked two things: every application caller already used the service-role key, and no other database function called these four. So the revoke changed nothing that worked.
2. Make the test call the function, not just the table
We added 4 assertions to web/tests/rls/cross-user-rls.ts. The bare anon key and a second signed-in founder each try to move founder A's balance through each of the four functions. The test passes only if every call is refused and the balance is unchanged.
3. Prove the test can fail
A test that cannot fail is not evidence. We re-opened the grants locally and ran the probe again. It reported 4 money leaks, the same 10 to 100,010 jump. We re-applied the migration and it reported 0. Then we ran the full probe: 113 passed, 0 failed.
4. Push to production with a dry run first
supabase migration list --linked showed this file as the only pending migration. supabase db push --dry-run named only it. Then supabase db push --linked applied it. Afterwards we confirmed the four functions were executable by service_role only.
5. Check whether anyone used the hole
A fix does not answer whether the hole was used. We read the credit ledger, credit_transactions, in production: 202 rows, and the largest positive amount was 2. A mint through refund_credits would show up as a large positive row. There was none. That is the honest limit of what we know: no sign of use in the ledger, not proof that nobody tried.
The rule we keep now
Every new SECURITY DEFINER function in our migrations ships with its revoke in the same file. Our Stripe webhook functions, stripe_claim_webhook_event and stripe_finish_webhook_event, were written that way: search_path pinned and execute revoked from the public roles on the day they were created.
If you build on Supabase, the 10-minute version is:
- List every function in
public, and especially everySECURITY DEFINERone. - For each, ask: should the browser ever call this? If not, revoke from
public, anon, authenticatedand grant toservice_role. - If the browser should call it, the function must check
auth.uid()itself. RLS will not do it for you. - Add one test that calls the function with the anon key and expects a refusal.
- Break the grant on purpose once, and watch that test go red.
Founders who build with Cursor, v0 and Supabase ship fast because the defaults are good. This is one place where the default is the hole. Venturehand is built by two people, and we found this in review, not in an incident report. That was luck as much as process.
If you want a second pair of eyes on the rest of your launch, our free tools look at your page from the outside, the way a customer does.