HomeGuidesSupabase SECURITY DEFINER functions anyone can call via rpc
Guide
Supabase SECURITY DEFINER functions anyone can call via rpc
Any function in your public schema is an API endpoint, and a SECURITY DEFINER one skips RLS. Find who can call it with the anon key, then lock it down and test.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Every function in your Supabase public schema is an HTTP endpoint at /rest/v1/rpc/<name>. Postgres lets every role execute a new function by default, and a signed-out request with your publishable (or legacy anon) key is the anon role. If the function is SECURITY DEFINER, it runs as its owner, usually postgres, and Row Level Security does not apply inside it. So a definer function that takes a user_id argument hands any visitor any user's rows, and one that updates a row lets them write it.
The fix is short: revoke EXECUTE from public and anon (both, not one of them), stop trusting ids passed in from the client, read the caller from auth.uid(), pin search_path = '', and move helpers your policies use into a schema the API does not expose. Then prove it with a logged-out curl. This guide shows each step with code we ran, plus one SQL query that lists every risky function in your project.
Supabase views and functions that bypass RLS covers views and when a definer function is justified. This page goes deeper on functions you call with supabase.rpc(). For the advisor warnings these produce, see Supabase Security Advisor warnings explained.
Checked and tested on October 21, 2026 against the Supabase database functions, Securing your API, RLS, and API keys docs, the Supabase Security Advisor lint SQL in the open source splinter repo, and the PostgreSQL and PostgREST docs linked below. We ran every SQL block and the curl commands on this page on PostgreSQL 17.11 behind PostgREST 16.5 (the API layer Supabase uses) with @supabase/supabase-js 2.118.0 and Supabase-style roles and default grants: 63 checks, all passing. VibeLocked is independent and not affiliated with Supabase.
The short version
- Every function in an exposed schema is public API. PostgREST runs any function there that the caller's role can execute, over POST, and over GET if it is
stableorimmutable. - Two grants, not one. Postgres grants
EXECUTEtoPUBLIC, and Supabase adds a direct grant toanon,authenticated, andservice_role. Revoke frompublic, anontogether, plusauthenticatedwhen signed-in users should not call it. - Never trust a
user_idargument. Drop the parameter and read the caller from(select auth.uid()). - Prefer
SECURITY INVOKER. Then RLS does the work. UseSECURITY DEFINERonly when the function must read something the caller cannot, and then check the caller yourself. - Pin
set search_path = ''and schema-qualify every name in the body. - Helpers your RLS policies call go in a
privateschema, notpublic. - Fix the defaults so the next function an AI tool writes is not public on day one.
- Test with curl and only the publishable key. The SQL editor runs as
postgresand proves nothing.
Why anon can call your function
Three facts stack up.
PostgREST exposes functions. Its functions docs say: "Every function in the exposed schema and accessible by the active database role is executable under the /rpc prefix." supabase.rpc('get_user_orders', { p_user_id }) is just a POST to /rest/v1/rpc/get_user_orders. If the function does not modify the database, it "will also run under the GET method," so a plain link can call it. A function that returns rows also accepts the usual filters, like ?select= and limit.
Everyone can execute a new function. The PostgreSQL privileges docs list EXECUTE on functions among the privileges granted to PUBLIC by default, and CREATE FUNCTION repeats it: "by default, execute privilege is granted to PUBLIC for newly created functions." On top of that, Supabase's Securing your API guide says that on existing projects, new functions in public also get execute for anon, authenticated, and service_role through default privileges. Supabase's database functions guide sums it up: "Any role can call it by default, including anon."
The publishable key is the anon role. Supabase's API keys guide says a request with the publishable key and no signed-in user runs as anon. That key ships in your frontend, and so do your function names, because every supabase.rpc('...') call is in your JavaScript bundle.
Then SECURITY DEFINER removes the last guard. Postgres runs it "with the privileges of the user that owns it," and Supabase's functions guide warns that a function made in the Dashboard or by a migration is owned by postgres, "which bypasses Row Level Security." The RLS guide is blunt: "A security definer function in an exposed schema is callable over the Data API with the creator's privileges. Never create one in a schema listed under 'Exposed schemas'."
What about Supabase's change to stop exposing new tables automatically (rolling out as the default for new projects since May 30, 2026, and scheduled for existing projects on October 30, 2026, per the Supabase announcement)? It does not close this hole for functions. The Securing your API guide says that even with automatic exposure off, "New functions are callable by every role," because the PUBLIC grant is a global Postgres default. For what the change does to tables, and how to grant them without opening data to anon, see Supabase 42501 permission denied after Oct 30.
What AI tools write
Ask Cursor, Lovable, Bolt, or v0 to "load the user's orders" and hit an RLS error, and you often get a definer function that takes the user id as an argument. Ask it to "upgrade the user after checkout" and you get the same shape for a write:
-- "Load the orders for the dashboard"
create or replace function public.get_user_orders(p_user_id uuid)
returns setof public.orders
language sql
security definer
stable
as $$
select * from public.orders where user_id = p_user_id;
$$;
-- "Upgrade the user after checkout"
create or replace function public.set_user_plan(p_user_id uuid, p_plan text)
returns void
language sql
security definer
as $$
update public.profiles set plan = p_plan where id = p_user_id;
$$;
Both tables had RLS enabled with "users read their own rows" policies. In our test, a signed-out request to /rest/v1/orders with only the anon key returned 0 rows, as it should. Then, with the same key and no session:
POST /rest/v1/rpc/get_user_orderswith Bob's id returned both of Bob's orders, shipping address included.GET /rest/v1/rpc/get_user_orders?p_user_id=...did the same, because the function isstable.supabase.rpc('get_user_orders', { p_user_id })from supabase-js with only the anon key returned them too.POST /rest/v1/rpc/set_user_planupgraded a free account topro(HTTP 204). Nobody needs a victim's id for this one: an attacker signs up and upgrades themselves. The same call over GET was refused with 405, because a volatile function only runs in a read-write transaction.- Signed in as Alice, passing Bob's id returned Bob's orders. Revoking from
anondoes not fix this part.
User ids are not secret. They show up in URLs, storage paths, created_by columns, and API responses, so treat "they would need the UUID" as no protection. This is the same bug as an IDOR, moved into the database.
Find them: one audit query
Run this in your project's SQL editor. It lists every SECURITY DEFINER function in your exposed schemas, who can call it, its raw grants, whether search_path is pinned, and whether its body reads the caller's identity at all. Trigger functions are left out because they cannot be called through /rpc.
select p.oid::regprocedure as function,
pg_catalog.pg_get_userbyid(p.proowner) as owner,
pg_catalog.has_function_privilege('anon', p.oid, 'execute') as anon_can_call,
pg_catalog.has_function_privilege('authenticated', p.oid, 'execute') as signed_in_can_call,
coalesce(p.proacl::text, 'default: PUBLIC can execute') as grants,
exists (
select 1 from unnest(coalesce(p.proconfig, '{}')) as c
where c like 'search_path=%'
) as search_path_pinned,
p.prosrc ~* 'auth\.(uid|jwt)\(' as reads_caller_identity
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where p.prosecdef
and p.prokind = 'f'
and p.prorettype <> 'pg_catalog.trigger'::pg_catalog.regtype
and n.nspname = any (array['public']) -- add every schema under "Exposed schemas"
and not exists (
select 1 from pg_catalog.pg_depend d
where d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass
and d.objid = p.oid
and d.deptype = 'e'
)
order by anon_can_call desc, signed_in_can_call desc, 1;
How to read it:
anon_can_call = true: a signed-out visitor can run it. Fix it today unless it is meant to be public.signed_in_can_call = true: anyone who signs up can run it. The lint 0029 docs put it plainly: with open signup, "the audience forauthenticatedis effectively the public internet."grants:=X/postgresat the start meansPUBLIChasEXECUTE(the empty name before=isPUBLIC).default: PUBLIC can executemeans nobody has touched the grants yet.search_path_pinned = false: thefunction_search_path_mutablefinding.reads_caller_identity = falseon a function that returns or changes user data: it is trusting its arguments.
In our test the query listed both AI functions with anon_can_call and signed_in_can_call true, an unpinned search path, and no identity check, and it listed nothing callable after the fixes below. If you expose more schemas than public (Dashboard, API settings, Exposed schemas), add them to the array.
The Security Advisor finds the same functions. Lint 0028_anon_security_definer_function_executable ("Public Can Execute SECURITY DEFINER Function") and 0029_authenticated_security_definer_function_executable check has_function_privilege for anon and authenticated on definer functions in the exposed schemas; we ran their SQL from splinter on the test database and both flagged both functions. The lint source also notes these functions are reachable through /graphql/v1 when pg_graphql is installed. Lint 0011_function_search_path_mutable flags any function without a pinned search_path.
Fix 1: revoke EXECUTE from public and anon, together
For a function only your server should call, like set_user_plan from a Stripe webhook, revoke it from every API role except the one your server uses:
revoke execute on function public.set_user_plan(uuid, text)
from public, anon, authenticated;
grant execute on function public.set_user_plan(uuid, text) to service_role;
After this, a signed-out call returned 401 with 42501 permission denied for function set_user_plan, a signed-in call returned 403, and the plan did not change. A server call with the secret (service_role) key still worked. Keep that key on the server: anon key vs service role key explains why.
Why both public and anon? We tested each half fix on get_user_orders:
| What you revoke | Signed-out call |
|---|---|
from public only |
Still 200 with rows (Supabase's direct grant to anon) |
from anon only |
Still 200 with rows (anon is a member of PUBLIC) |
from public, anon |
401, 42501 |
Supabase's functions guide says the same: revoke "from public and from each role holding a direct grant, such as anon." A revoke changes who can call the function. It does not change what the function returns, which is the next fix.
Fix 2: stop trusting the user_id argument
Even with anon revoked, any signed-in user could still call get_user_orders with someone else's id. In our test, Mallory read Bob's orders that way. Supabase's functions guide calls this out: an execute grant "doesn't limit the rows a permitted role gets back, so granting to authenticated still returns any user's row to every signed-in caller."
The best fix is to make it an invoker function with no id parameter, so RLS on orders applies:
drop function public.get_user_orders(uuid);
create function public.get_my_orders()
returns setof public.orders
language sql
security invoker
set search_path = ''
stable
as $$
select * from public.orders where user_id = (select auth.uid());
$$;
revoke execute on function public.get_my_orders() from public, anon;
grant execute on function public.get_my_orders() to authenticated;
Results: the old /rpc/get_user_orders returned 404 (PGRST202, function not found), a signed-out call to get_my_orders returned 401, Alice got her 1 order, Bob got his 2 (over GET too), and Mallory got 0. Passing p_user_id returned 404, because no function takes that argument. The where clause is not the security; RLS is. Supabase's RLS performance guide recommends filtering in the query too, because it helps Postgres use an index.
Note the drop function. Postgres treats a function with different arguments as a different function, so create or replace with a new signature leaves the old, vulnerable one in place.
When it really has to be SECURITY DEFINER
Sometimes a function must read a table the caller cannot, for example a counts summary over a locked table. Then the function is the only guard, so check the caller inside it:
drop function public.get_user_orders(uuid);
create function public.get_my_orders()
returns setof public.orders
language plpgsql
security definer
set search_path = ''
stable
as $$
begin
if (select auth.uid()) is null then
raise exception 'not signed in' using errcode = '42501';
end if;
return query
select * from public.orders where user_id = (select auth.uid());
end;
$$;
revoke execute on function public.get_my_orders() from public, anon;
grant execute on function public.get_my_orders() to authenticated;
We ran the same tests against this version and got the same results. We also granted it to anon by mistake on purpose: the call failed with "not signed in" instead of returning rows, because auth.uid() is null for a signed-out request. Two habits make this safe:
- Filter by
(select auth.uid()), never by an argument, and return only the columns the caller needs. - For admin functions, check a table you control, like
profiles.is_admin, notuser_metadata. Supabase's RLS guide warns thatuser_metadata"can be modified by authenticated end users." In our test, a definer function that checkedauth.jwt() -> 'user_metadata' ->> 'role' = 'admin'handed all orders to a user who had set that field.
If the definer function builds SQL from its arguments, read SQL injection in Prisma, Drizzle, and Supabase first: injection inside a definer function runs as postgres.
Fix 3: pin search_path = ''
A function without a pinned search_path resolves unqualified names, like log_event(...) or orders, using the caller's settings. PostgreSQL's Writing SECURITY DEFINER Functions Safely section says the path should "exclude any schemas writable by untrusted users," so nobody can create objects "that mask objects intended to be used by the function."
We tested it with a low-privilege database login (think of a reporting user) that could not update profiles directly. It created its own log_event function, put its schema first in its search_path, and called a definer function whose body called log_event without a schema. The definer function ran the attacker's version as postgres, and the attacker's account became an admin. After set search_path = '', the same call failed with function log_event(text) does not exist. Once the body said public.log_event, it worked and ignored the attacker's copy.
Through the Data API, a caller cannot change search_path, so on most Supabase projects this is hardening. It costs one line, and the Security Advisor flags every function without it (lint 0011). Put set search_path = '' in every function definition, then qualify every table and function inside with its schema. The Security Advisor guide has the alter function form and a query for functions that still lack it.
Fix 4: helpers for RLS policies go in a private schema
The most common reason AI tools write definer functions is RLS: a policy needs to read another table (an admin flag, a membership row), and doing it directly causes recursion or permission errors. That helper should never be an API endpoint:
create schema if not exists private;
create function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select coalesce(
(select p.is_admin from public.profiles p where p.id = (select auth.uid())),
false
);
$$;
revoke execute on function private.is_admin() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;
create policy "Admins read all orders"
on public.orders for select
to authenticated
using ( (select private.is_admin()) );
The policy still works: admin Alice read all 3 orders, Bob read his 2, signed-out requests read 0. But /rest/v1/rpc/is_admin returned 404, and a request that asked for the private schema with a Content-Profile: private header got 406 PGRST106, because the schema is not exposed. Lint 0028 only checks exposed schemas, so it stopped flagging the helper. The revoke and grants still matter: authenticated needs USAGE on the schema and EXECUTE on the function for the policy to run, and nobody else needs either. The recursion guide has the membership version of this pattern.
Do not move a function your server calls with supabase-js into private: supabase-js goes through the same Data API, so it cannot reach it either. Keep those in public with grants to service_role only, as in Fix 1.
Fix 5: stop the next function from being public
The fixes above cover the functions you have. The next migration an AI tool writes will create a new function with the same default grants. Change the defaults:
-- 1. Postgres grants EXECUTE to PUBLIC through a global default.
-- Revoke it globally (no "in schema"), or the revoke does nothing.
alter default privileges for role postgres
revoke execute on functions from public;
-- 2. Supabase also grants EXECUTE to the API roles in public.
alter default privileges for role postgres in schema public
revoke execute on functions from anon, authenticated;
The first statement has no in schema, on purpose. PostgreSQL's ALTER DEFAULT PRIVILEGES docs say "you cannot revoke privileges per-schema if they are granted globally," and Supabase's functions guide repeats it: "A revoke limited to one schema has no effect on a global default." We checked: after alter default privileges for role postgres in schema public revoke execute on functions from public, a new definer function was still callable by anon with HTTP 200.
After the block above, a new function in public returned 401 for signed-out calls and 403 for signed-in ones, and service_role could still call it. Three things to know:
- Existing functions do not change. Default privileges only apply to functions created later, as
postgres. Run the audit query and fix what is already there. - Grant every new function explicitly, including helpers in
private. In our test a newprivatefunction was no longer executable byauthenticated, so a policy that calls it would fail until you addgrant execute ... to authenticated. - Drop and recreate resets grants. In our test,
create or replacewith the same arguments kept the revoke, butdrop functionfollowed bycreate functionbrought the default grants back andanoncould call it again. Revoke in the same migration, right after thecreate. PostgreSQL suggests doing both in one transaction so there is no window where everyone can call it.
Test it as a signed-out visitor
Run this from your own terminal against your own project, with only the publishable key (or the legacy anon key). Never use the secret or service_role key for this test: it is allowed to call everything, so a broken function looks fine.
export SUPABASE_URL="https://<project-ref>.supabase.co"
export SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."
# Read function: should be 401 or 404, never 200 with rows
curl -i -X POST "$SUPABASE_URL/rest/v1/rpc/get_user_orders" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"p_user_id": "<a-real-user-uuid>"}'
# Same function over GET (works for stable and immutable functions)
curl -i "$SUPABASE_URL/rest/v1/rpc/get_user_orders?p_user_id=<a-real-user-uuid>" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY"
# Write function: try it on your own test account, never a real user
curl -i -X POST "$SUPABASE_URL/rest/v1/rpc/set_user_plan" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"p_user_id": "<your-test-user-uuid>", "p_plan": "pro"}'
Against the vulnerable functions, this returned 200, 200, and 204 (the plan changed). After Fix 1 and Fix 2 it returned 404, 404, and 401, and the plan stayed free. A 401 or 403 with code 42501 means the role cannot execute it. A 404 with PGRST202 means no such function with those arguments, so double check the name and argument names before you celebrate: calling the right function with the wrong argument names also returns 404.
You can also simulate the anon role in the SQL editor, inside a transaction that rolls back:
begin;
set local role anon;
select * from public.get_user_orders('<a-real-user-uuid>'); -- expect permission denied
rollback;
Before the fix this printed Bob's orders. After the revoke it failed with permission denied for function get_user_orders. To keep it fixed, add a test to CI: testing Supabase RLS in CI has a guardrail test that fails the build when anon can execute a definer function.
Common AI-generated mistakes
- Adding
security definerto make an RLS error go away. The error was RLS doing its job. Fix the policy or the grant instead. - Letting a definer function's errors reach the client. Postgres builds error details for the function's owner, so in our test a check error returned another user's whole row, email and Stripe id included. Validate inputs and raise a safe message, as shown in Supabase error details that leak hidden rows.
- A
p_user_id,user_email, orowner_idparameter on a function that returns or changes per-user data. - Revoking from only
publicor onlyanon. Both still left the function callable in our test. - Granting to
authenticatedand calling it safe. With open signup, that is anyone. - Admin checks on
user_metadataor on a flag the client sends. create or replacewith a new argument list to "fix" a function, which leaves the old overload callable. In our test the new overload was also callable byanon, because it was a brand new function with default grants.- Dropping and recreating a function in a migration without repeating the revoke.
alter default privileges ... in schema public revoke execute on functions from public, which does nothing.- RLS helper functions in
public, where they become endpoints. - Testing in the SQL editor as
postgresor with the secret key, which bypasses everything.
Checklist
- The audit query shows no definer function with
anon_can_call = trueunless it is meant to be public - Every definer function with
signed_in_can_call = truereads the caller from(select auth.uid())and takes no user id argument - Functions only the server calls have
EXECUTErevoked frompublic, anon, authenticatedand granted toservice_role - Functions that only need the caller's own rows are
SECURITY INVOKER - Every function sets
search_path = ''and schema-qualifies names - RLS helper functions live in
private, withEXECUTEgranted only toauthenticated - Default privileges changed globally for
PUBLICand per schema foranonandauthenticated - Old overloads dropped; migrations revoke right after every
create function - Security Advisor shows no
0028,0029, or0011findings you have not accepted on purpose - curl with only the publishable key gets 401, 403, or 404 for every private function
- A CI test fails if
anoncan execute a definer function
Then check your tables with the Supabase RLS checklist, and run the policy audit in RLS on but using (true) to find table policies that let every row through.
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Lovable, or Bolt. Share code and table names only, never keys, passwords, or production data.
Audit every Postgres function in this repo's Supabase migrations. For each function in the public schema (or any schema exposed to the Data API), tell me: is it SECURITY DEFINER, does it set search_path = '', does it take a user id, email, or owner id as an argument, does its body check (select auth.uid()), and which roles get EXECUTE. Then write one new migration, and show it to me before running anything: 1) Functions only the server calls: revoke execute from public, anon, authenticated and grant execute to service_role. 2) Functions that return or change the caller's own data: drop the user id parameter, read the caller from (select auth.uid()), and make them SECURITY INVOKER unless they must read a table the caller cannot; if they must stay SECURITY DEFINER, raise an error when auth.uid() is null. 3) Helper functions called from RLS policies: move them to a schema named private, revoke execute from public and anon, grant usage on the schema and execute to authenticated. 4) Add set search_path = '' to every function and schema-qualify every name in the bodies. 5) Drop any old overloads. 6) Add alter default privileges for role postgres revoke execute on functions from public; (no in schema) and alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated;. 7) Do not use using (true), do not disable RLS, and do not check admin status from user_metadata. Finally, list every supabase.rpc() call in the frontend and say which role will call it.
Read the migration before it runs, try it on a branch or local database, then run the curl test above. More prompts are in the security review prompt guide.
Sources
- Supabase: Database functions (security definer, function privileges)
- Supabase: Securing your API (default privileges, functions callable by every role)
- Supabase: Row Level Security (security definer functions, exposed schemas, user_metadata)
- Supabase: Database advisors
- Supabase: RLS performance (filter in the client query too)
- Supabase: API keys (publishable key maps to anon)
- Supabase: Postgres roles
- Supabase JavaScript reference: rpc()
- Supabase changelog discussion: tables not exposed to the Data API automatically
- splinter: lint 0028 anon_security_definer_function_executable (SQL)
- splinter: lint 0029 authenticated_security_definer_function_executable (SQL)
- splinter docs: lint 0028 (revoke from anon alone usually leaves it callable by signed-up users)
- splinter docs: lint 0029 (authenticated is effectively the public internet)
- splinter: lint 0011 function_search_path_mutable (SQL)
- PostgREST: Functions as RPC (exposed schema, GET for read-only functions)
- PostgREST: Transactions (access mode by function volatility)
- PostgREST: Authentication (anonymous role, user impersonation)
- PostgREST: Schemas (exposed schemas, Content-Profile)
- PostgreSQL: CREATE FUNCTION (SECURITY DEFINER, writing definer functions safely, CREATE OR REPLACE)
- PostgreSQL: Privileges (default EXECUTE to PUBLIC, ACL abbreviations)
- PostgreSQL: ALTER DEFAULT PRIVILEGES (per-schema vs global)
- PostgreSQL: REVOKE
- PostgreSQL: System information functions (has_function_privilege)
- PostgreSQL: pg_proc catalog (prosecdef, proconfig, proacl)
Questions
Can the anon key call my Supabase database functions?
Yes, unless you revoke it. PostgREST serves every function in an exposed schema (public by default) at /rest/v1/rpc/<name> to any role that has EXECUTE on it. Postgres grants EXECUTE on new functions to PUBLIC, and Supabase has also granted it to anon, authenticated, and service_role in public. A signed-out request with only your publishable or anon key runs as anon, so it can call the function.
Does supabase.rpc() bypass Row Level Security?
Only when the function is SECURITY DEFINER. A SECURITY INVOKER function (the Postgres default) runs as the caller, so the table policies still apply. A SECURITY DEFINER function runs as its owner, which is postgres for functions made in the SQL editor or migrations, and postgres bypasses RLS. Inside a definer function, the only protection is the code you write, such as a check on auth.uid().
Why does anon still have EXECUTE after I revoked it?
Because there are two grants. Postgres gives EXECUTE to PUBLIC, a group every role belongs to, and Supabase adds a direct grant to anon. Revoking from public alone leaves the anon grant, and revoking from anon alone leaves PUBLIC. In our test both half fixes still returned rows. Run revoke execute on function public.fn(args) from public, anon; and also authenticated if signed-in users should not call it.
Does alter default privileges in schema public revoke execute from public work?
No. Postgres grants EXECUTE to PUBLIC through a global default, and its docs say you cannot revoke per schema what is granted globally. We confirmed a new function was still callable by anon after that statement. Use alter default privileges for role postgres revoke execute on functions from public; without in schema, plus a per schema revoke from anon and authenticated.
Is a function in a private schema safe from the API?
It cannot be called through /rest/v1/rpc unless that schema is listed under Exposed schemas in your API settings. In our test the endpoint returned 404 and asking for the schema returned 406 PGRST106, while an RLS policy could still call the helper. It still needs search_path = '' and a revoke from public and anon, because anyone with a direct database connection could call it.