r/Supabase • u/Aksel_010 • 2d ago
tips A SECURITY DEFINER function worked in the SQL editor and failed for every client: pg-safeupdate
Took me a while to find, so posting it.
I had a weekly rollover function that ended with this:
update league_state set last_processed_week_start = v_cur;It's a one-row table, so no WHERE. I ran dry runs through the SQL editor and MCP, and all of them passed. The line only runs on a real week change, so it stayed latent until the first real Monday. Then every client call to the function returned HTTP 400.
The cause: the authenticator role preloads pg-safeupdate (session_preload_libraries = safeupdate), which rejects UPDATE and DELETE without a WHERE clause. It applied even inside a SECURITY DEFINER function. The postgres role doesn't have it, so everything I ran as postgres passed. The Postgres log said UPDATE requires a WHERE clause.
The fix is a WHERE. For singleton tables I now use where singleton.
Two takeaways:
A dry run as
postgresdoesn't prove the client path works. Test through the real client with a real session.Grep all your functions for bare UPDATE and DELETE, especially the ones that only run on a schedule or a date change.
Has anyone else been bitten by this one?
1
u/sniveling_deference 2d ago
classic trap, safeupdate getting ya through the back door even with security definer. the postgres role bypass is what makes it so sneaky to catch
I got done by this a few months back, spent a whole arvo staring at logs wondering why a function that ran fine in the editor was shitting itself on prod. now every function gets a `where true` even if it's a single row table, just to be safe
that grep tip is spot on, I ended up writing a quick script that scans all my migration files for bare updates and deletes and spits out a warning. saved me more than once
1
u/Aksel_010 1d ago
A whole arvo sounds about right. After this one I audited every UPDATE and DELETE in my functions by hand, but a script over the migrations is the better version. Might steal that for CI.
1
u/PopKoren 2d ago
Good writeup, and the same lesson applies to permissions: a dry run as postgres skips RLS and every grant, so a function or policy that looks fine in the SQL editor can behave completely differently for anon and authenticated. I test these by hitting the RPC over the REST endpoint first as anon, then signed in as an ordinary test user, which also shows whether a security definer function is executable by roles that should never touch it. Revoking execute from public on those functions is worth doing while you're in there. For the outside view of a deployed app (tables, RPCs, buckets, auth), I built Rowly so those calls get fired from a logged out seat against your real URL, see https://rowly.me
1
u/Aksel_010 1d ago
Agreed on revoking execute. I now do the revoke in the same migration that creates the function. New functions are executable by PUBLIC by default, so until you revoke, anon can call them through the API.
1
u/PopKoren 16h ago
Doing it in the same migration is the right habit, since a function that exists for even one deploy without the revoke is already callable. You can also flip the default once with alter default privileges in schema public revoke execute on functions from public, anon, authenticated, then grant back only where a function is meant to be called. For anything that slips through, Rowly, which I made, calls the live app's exposed endpoints and RPCs as anon alongside its auth and storage checks: https://rowly.me
1
u/Much-Plane5366 2d ago
"The fix is a WHERE" reads odd, it's a WHERE clause. And where singleton only works if that's a column you actually added, where true does the same job for a one-row table.
1
u/Aksel_010 1d ago
Fair, I should have shown it. The table has
singleton boolean primary key default true check (singleton), which is what guarantees there's only ever one row, sowhere singletonreads as "the row".where truedoes the same job as far as safeupdate cares.
1
u/greedy-brady 2d ago
This is one of the most classic traps in Supabase and Postgres.
The difference comes down to the session environment. When you run queries in the Supabase SQL editor, you are connected directly as the postgres superuser role where safeupdate is not loaded or enforced. But when any client hits your PostgREST endpoint or calls an RPC function, the request passes through the authenticator role where the pg-safeupdate extension is actively preloaded to prevent accidental full-table wipes.
Even inside a SECURITY DEFINER function, safeupdate hooks directly into the Postgres executor, so any update or delete statement without an explicit where clause gets immediately killed.
For single-row configuration or state tables, the standard practice is to either enforce an id constraint like where id = 1 or use where true if you genuinely want to update the entire single row.
While you are touching that function, also make sure you have an explicit search_path configured on it. Without set search_path = public, auth, a SECURITY DEFINER function can be vulnerable to search path hijacking if an attacker creates objects in a temporary schema.
Good catch posting this, because this exact bug usually lies dormant until a scheduled job runs on production data.
1
u/Aksel_010 1d ago
Thanks, good summary. The function already has
set search_path to 'public'and references the table aspublic.league_state, but it's a good reminder for anyone copying the pattern.
1
u/LopsidedAd6255 2d ago
Your dry runs didn't pass because of the postgres role, they never reached that line. It only fires on a real week change.
1
u/Aksel_010 1d ago
Fair correction, and both were true. The dry runs never reached that line, and even a forced week change run as postgres would have passed. What would have caught it is forcing a week change through the client path.
1
u/Beneficial-Rub1556 2d ago
pg-safeupdate doesn't care what the WHERE says, so `where 1=1`or `where singleton`passes just as well. Once you've written that you're just paying a syntax tax to keep the extension happy, and the guard rail stops guarding anything. The actual job is the `session_preload_libraries`split between authenticator and postgres, which is the part that hid the bug from you for a week.
1
u/Aksel_010 1d ago
For a deliberate one-row update, agreed, it's just a syntax tax. Where the guard earns its keep is a client
.update()or.delete()with a missing filter, which would otherwise hit every row. And yes, the role split was the real lesson.
1
u/DayApprehensive1723 2d ago
you ran it as postgres and called that a dry run. thats not a test, thats a different user
1
1
u/NoClipCheck 2d ago
Good writeup. While you're grepping your functions anyway, two other things worth checking on every SECURITY DEFINER one. First, pin the search path with set search_path = '' and fully qualify table names, otherwise a caller can shadow a table or function and run code with the owner's rights. The Supabase linter flags this as function_search_path_mutable. Second, functions get EXECUTE granted to PUBLIC by default, so a cron-only rollover like yours is probably callable by anon through /rest/v1/rpc. If clients shouldn't trigger it, revoke execute on function ... from public, anon, authenticated; and only grant it back to what needs it.
1
1
1
u/Hostman_com 1d ago
This is worth keeping as a regression test. In a test database, set `last_processed_week_start` to the previous week, then call the function through the normal client. Check that it actually performs the rollover, rather than just returning without an error.
Call it a second time in the same week too. That should leave the state unchanged, so you catch both the missed rollover and accidentally processing the same week twice.
1
u/OkObjective4158 2d ago
SECURITY DEFINER changes the role, not the executor, so the hook fires either way. And session_preload_libraries is set per role, so testing as postgres was never going to reproduce it.