VibeRaven

Can anyone call my Supabase RPC functions with the anon key?

Yes, unless you revoked it. Postgres gives EXECUTE on every new function to PUBLIC, which every role belongs to, and Supabase also grants it to anon and authenticated on functions in the public schema. So anyone holding the publishable (anon) key your frontend ships can call a function in public at /rest/v1/rpc/<name>. For a security definer function that is a problem: it runs with its owner's rights, and on Supabase that is usually postgres, which bypasses row level security, so your policies do not limit what it reads or writes. Close each function only your server calls by revoking EXECUTE from public, anon and authenticated in one statement; leave out any of the three and it stays callable. A helper your policies call, such as has_role, needs the opposite: keep EXECUTE for the roles whose policies call it, and move it to a schema the API does not expose.

Why this matters

If a coding agent wrote your migrations, look for Postgres functions in a few shapes: a has_role check that policies call, a function the frontend calls with supabase.rpc(), a trigger that creates a profile row at sign-up, a helper written for an admin task. Supabase's Securing your API page says row level security does not apply to functions, so EXECUTE is the only thing deciding who can run one, and its Advisors docs say /rest/v1/rpc accepts any function name the caller has EXECUTE on, whether or not your app ever calls it. The Security Advisor reports these as lints 0028 (callable without signing in) and 0029 (callable by signed-in users). Symbiotic Security, a security vendor, reports that 44 of the 1,072 vibe-coded Supabase sites it scanned had "RPC functions exposed and callable", which it rates High; that is their figure, and we did not reproduce it.

What we do not know yet

  • Whether a hosted project returns exactly what this page shows. No hosted project was tested for this page. The results quoted here come from a local stack (see the local test below). A commenter in Supabase's Data API announcement thread reported the same default on a hosted project in August: a new function stayed executable by anon after the per-schema revoke from public.
  • Whether Supabase will change the PUBLIC default. Supabase staff answered that report on 2026-08-08: it "is not handled on purpose as that is the PostgreSQL default", and revoking it "can only be done in a global way (impacting all postgres clients not only Data API/GraphQL)", so it "needs further discussion and handling". As of 2026-10-01 we found no later answer from Supabase in that thread.

How to find each callable function and decide what to do with it

  • List the security definer functions in every schema the Data API exposes, who owns them, and whether a signed-out or signed-in caller can run them. Supabase's Advisors lints 0028 and 0029 flag these in any user schema, not only public, so if your API settings list other schemas under Exposed schemas, add each one to the list in the where clause. This is the owner query from Supabase's Advisors docs, with its security_definer column swapped for two that show who can call each function, its schema filter turned into a list, and trigger functions left out: a function that returns trigger, such as one that creates a profile row at sign-up, can only run as a trigger, so there is nothing to call at /rest/v1/rpc. Run it in the SQL editor against the live project, because a function made in the dashboard is not in your migrations:
    select
        p.proname,
        pg_get_function_identity_arguments(p.oid) as args,
        pg_catalog.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 signed_in_can_call
    from pg_catalog.pg_proc p
    join pg_catalog.pg_namespace n on n.oid = p.pronamespace
    where n.nspname in ('public')  -- add every other schema listed under Exposed schemas
      and p.prosecdef
      and p.prorettype <> 'pg_catalog.trigger'::pg_catalog.regtype
    order by p.proname;
  • Search the repo as well, for functions a migration adds that have not reached the project yet. This search covers every schema, not only public:
    grep -rni "security definer" supabase/migrations
  • Sort each function into one of the cases below before you change anything. Supabase's Advisors docs give three fixes for lints 0028 and 0029: revoke EXECUTE, switch to security invoker, or keep it and make it safe to call. A helper your policies call is a fourth case, and the first fix breaks it.
  • Only your server calls it, for example from an Edge Function, a route handler that uses the secret key, or a scheduled job: revoke EXECUTE from public, anon and authenticated in one statement. Leaving out public leaves the Postgres default, and leaving out anon or authenticated leaves Supabase's grant. On our local stack (see the local test below), a function revoked from public alone, or from anon alone, still returned another user's row to a signed-out caller. The secret key could still call it after the full revoke, because service_role keeps its own grant.
    revoke execute on function public.your_function(uuid) from public, anon, authenticated;
  • A helper your policies call, such as has_role: do not just revoke it. Postgres runs a policy expression "with the rights of the user running the overall query", so every role whose policy calls the helper needs EXECUTE on it. On our local stack (see below), revoking has_role from public, anon and authenticated turned an admin's read of a table into 403 "permission denied for function has_role", and the same for every other signed-in user. First list each policy that calls it, with its command, the roles it applies to and both of its expressions, because the next step changes each one in place. A policy written with no TO clause applies to every role, because Postgres's CREATE POLICY docs make PUBLIC the default, so its roles show as {public} and it counts as a policy for anon too:
    select schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
    from pg_catalog.pg_policies
    where qual like '%has_role%' or with_check like '%has_role%';
  • Then move the helper to a schema the Data API does not expose, as Supabase's RLS guide does with a private schema. Grant USAGE on that schema and EXECUTE on the function to authenticated, and to anon as well if any row shows {public} or anon. On our local stack (see below), with an insert policy that had no TO clause and called the helper in its WITH CHECK, a signed-out insert failed with 401 "permission denied for function has_role" after the move until anon had both grants, and then got the usual row level security refusal. Next, point each policy at the new name with ALTER POLICY, one statement per row the query returned. Do not drop a policy and create it again from an example, because the new policy gets the example's command, roles and expressions instead of its own. Postgres's ALTER POLICY docs say it replaces only the clauses you give it and leaves the rest of the policy unchanged, and it cannot change the command or whether the policy is permissive or restrictive. Give it USING for a row with has_role in qual, WITH CHECK for a row with has_role in with_check, and both clauses for a row with it in both. Copy each expression from the query output and change public.has_role to private.has_role; pg_policies can print the name without public., as it did on our stack. Wrapping the call in (select ...), as below, is the form Supabase's RLS guide uses so the result is computed once per statement. Check that private is not listed under Exposed schemas in your API settings. Adapted from the RLS guide, for a has_role(user_id, role) helper and the two policies in the local test below:
    create schema if not exists private;
    
    create function private.has_role(_user_id uuid, _role public.app_role)
    returns boolean
    language sql
    stable
    security definer
    set search_path = ''
    as $$
      select exists (
        select 1 from public.user_roles
        where user_id = _user_id and role = _role
      )
    $$;
    
    revoke execute on function private.has_role(uuid, public.app_role) from public;
    grant usage on schema private to authenticated;
    grant execute on function private.has_role(uuid, public.app_role) to authenticated;
    -- and if any policy above shows {public} or anon:
    -- grant usage on schema private to anon;
    -- grant execute on function private.has_role(uuid, public.app_role) to anon;
    
    -- one alter policy per row the policy query returned
    alter policy "admins read reports" on public.reports
      using ((select private.has_role((select auth.uid()), 'admin')));
    
    -- a row with has_role in with_check gets with check instead of using
    alter policy "admins add reports" on public.reports
      with check ((select private.has_role((select auth.uid()), 'admin')));
  • Before you drop the old function, search the app for calls to it, in src or wherever your frontend code lives. A frontend often calls supabase.rpc('has_role', ...) to decide whether to show admin screens. Once the function is gone from public, that call returns 404 with code PGRST202, as in the local test below, and the admin check in the UI stops working. If the app calls it, change those calls first, for example to read the signed-in user's own row from user_roles under a policy that lets each user select only their own rows. The UI check only decides what to show; the policies decide what the database returns.
    grep -rnE "rpc\(['\"]has_role" src
  • When no policy and no app code calls the old function, drop it. Postgres refuses to drop it while a policy still uses it, and drop ... cascade would drop those policies too, so do not reach for it.
    drop function public.has_role(uuid, public.app_role);
  • It is meant to be called by anyone, such as a contact form or a public counter: keep EXECUTE, validate every argument inside the function, and limit what it reads or writes. Supabase's Advisors docs call a security definer function exposed to anon "effectively a public API endpoint".
  • Only signed-in users should call it: revoke it from public and anon, grant it to authenticated, and check auth.uid() inside before it touches anyone else's rows. Supabase's Advisors docs note that with open sign-up, a signed-in user can mean any throwaway account. On our local stack (see below), a function set up this way refused a signed-out call and answered a signed-in one.
    revoke execute on function public.your_function(uuid) from public, anon;
    grant execute on function public.your_function(uuid) to authenticated;
  • It does not need to bypass row level security: make it security invoker, so it runs as the caller and the policies on the tables it touches apply. Supabase's Database Functions page says to prefer security invoker, which is also the default.
    alter function public.your_function(uuid) security invoker;
  • Set search_path = '' on every security definer function you keep, and schema-qualify every name inside it. Supabase's RLS guide says that without a pinned search_path, a caller can point an unqualified name at their own object and run it with the function owner's privileges.
  • Revoke in the same migration that creates each new function, because a new function is callable by default again. A per-schema default does not change that. Supabase's Database Functions page suggests alter default privileges in schema public revoke execute on functions from public, and the Advisors quick reference lists public in the same per-schema form, but Postgres's ALTER DEFAULT PRIVILEGES docs say of that statement: "This command has no effect, unless it is undoing a matching GRANT", because per-schema defaults can only add to the global ones. On our local stack (see below), functions created after it, and after the per-schema lines on Supabase's Securing your API page, still returned rows to a signed-out caller. Postgres documents a form with no schema that does remove the PUBLIC default, but it applies to every function that role creates afterward, in every schema, and it leaves Supabase's own per-schema grant to anon and authenticated in public. On our local stack (see below), a function created after that form alone still returned another user's row to a signed-out caller. The one it closed there was created while the per-schema revokes from anon and authenticated were still in effect. Postgres's CREATE FUNCTION docs describe the narrow habit: create the function and set its privileges together, so it is never open to all. Put the revoke right after the create function:
    create function public.your_function(_id uuid)
    returns boolean
    language sql
    stable
    security definer
    set search_path = ''
    as $$
      select exists (select 1 from public.notes where id = _id)
    $$;
    
    revoke execute on function public.your_function(uuid) from public, anon, authenticated;
  • Then call each function you changed the way a signed-out visitor can, with only the publishable key and the function's own arguments as JSON. On our local stack (see below), a closed function returned 401 with code 42501 and "permission denied for function" followed by its name, and a function moved out of the exposed schema returned 404 with code PGRST202.
    curl -X POST "https://<project-ref>.supabase.co/rest/v1/rpc/your_function" \
      -H "apikey: <publishable key>" \
      -H "Content-Type: application/json" \
      -d '{"_id": "00000000-0000-0000-0000-000000000000"}'

Optional repo check

For AI-built Vercel + Supabase apps, `npx -y viberaven@1.6.1 check` reads `supabase/migrations` for security definer functions in the public schema that no migration revokes from public, anon or authenticated, and reports each one as `security_definer_function_executable` with its file and line and its /rest/v1/rpc path. Most come out as warnings with a revoke line to add, and that line names only the roles no migration has revoked yet: on a fixture, for a function revoked from public alone, it suggested `revoke execute on function public.all_notes_b() from anon, authenticated;`. A function revoked from public and anon and granted to authenticated, the signed-in only case above, comes out as info rather than a warning, with a reminder to check auth.uid() inside. On a fixture repo on 2026-10-01, with version 1.6.1, it flagged a function with no revoke, one revoked from public alone and one revoked from anon alone, and did not flag one revoked from all three. Its revoke line assumes only your server calls the function: it suggested the same revoke for a has_role helper that a policy calls, which would break that policy, so move a helper like that instead. It reads only the public schema, skips functions that return trigger, and does not see functions made in the dashboard or the grants your hosted project holds. It also counts a per-schema alter default privileges revoke from public as closing later functions, so on the fixture it did not flag a function created after one, a setup our local stack still let a signed-out caller run. Neither a warning nor an info finding fails the check on its own; in that run the terminal showed only the first function, and `.viberaven/agent-summary.md` listed four warnings, for those three functions and has_role, and one info finding, for the signed-in only function. It is advice, not a gate, and a repository check, not a live database or security test. A clean result does not prove the app is secure. It exits with code 1 when it finds a blocker and writes a `.viberaven` folder that it does not add to `.gitignore`.

What a local Supabase stack returned

Run on 2026-10-01 with Supabase CLI v2.119.0 (Postgres 17.11.0.002, PostgREST v16.4, Auth v2.197.0) and auto_expose_new_tables left out of supabase/config.toml, which on that version keeps the automatic grants to anon and authenticated. Migrations ran as postgres. public.notes has row level security on, one policy that lets a signed-in user read their own rows, and one row owned by another user; each all_notes function selects every row from it. Responses are copied from curl, with keys and tokens left out, and the PGRST202 body is cut to its code and message. No hosted project was tested.

Signed-out calls after each revoke

Each line is a POST to /rest/v1/rpc/<name> with only the publishable key, after the revoke in the comment above it. The last line is the same caller reading the table directly.

-- no revoke
all_notes    200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
-- revoke execute on function public.all_notes_b() from public;
all_notes_b  200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
-- revoke execute on function public.all_notes_c() from anon;
all_notes_c  200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
-- revoke execute on function public.all_notes_d() from public, anon, authenticated;
all_notes_d  401 {"code":"42501","details":null,"hint":null,"message":"permission denied for function all_notes_d"}
-- GET /rest/v1/notes, same caller
notes        200 []

A has_role policy, before and after the revoke

public.reports has one policy for authenticated: using (public.has_role((select auth.uid()), 'admin')). Before the revoke, a signed-out caller could also ask has_role about the admin's user id and got true.

admin      GET /rest/v1/reports       200 [{"id":1,"body":"quarterly numbers"}]
non-admin  GET /rest/v1/reports       200 []
signed out POST /rest/v1/rpc/has_role 200 true
-- revoke execute on function public.has_role(uuid, public.app_role) from public, anon, authenticated;
admin      GET /rest/v1/reports       403 {"code":"42501","details":null,"hint":null,"message":"permission denied for function has_role"}
non-admin  GET /rest/v1/reports       403 {"code":"42501","details":null,"hint":null,"message":"permission denied for function has_role"}

The same helper in a private schema

For this run we reset the stack and added a second policy to public.reports, for insert, with no TO clause: with check (public.has_role((select auth.uid()), 'admin')). Then we ran the private schema SQL from the checklist above, both alter policy statements included, and in the same block dropped the old function and reloaded the API's schema cache: drop function public.has_role(uuid, public.app_role); and notify pgrst, 'reload schema';. The 404 on /rest/v1/rpc/has_role below comes from that drop: in an earlier run, with public.has_role in place and its default grants, the same signed-out call returned 200 true. We made the calls below first without the anon grant and then with it. The pg_policies rows are from the policy query in the checklist, without the schemaname and tablename columns.

-- pg_policies before the move
    policyname      | permissive |      roles      |  cmd   |                           qual                           |                        with_check
admins add reports  | PERMISSIVE | {public}        | INSERT |                                                          | has_role(( SELECT auth.uid() AS uid), 'admin'::app_role)
admins read reports | PERMISSIVE | {authenticated} | SELECT | has_role(( SELECT auth.uid() AS uid), 'admin'::app_role) |
-- pg_policies after the move, both alter policy statements run
    policyname      | permissive |      roles      |  cmd   |                                          qual                                          |                                       with_check
admins add reports  | PERMISSIVE | {public}        | INSERT |                                                                                        | ( SELECT private.has_role(( SELECT auth.uid() AS uid), 'admin'::app_role) AS has_role)
admins read reports | PERMISSIVE | {authenticated} | SELECT | ( SELECT private.has_role(( SELECT auth.uid() AS uid), 'admin'::app_role) AS has_role) |
-- before the anon grant
signed out POST /rest/v1/reports      401 {"code":"42501","details":null,"hint":null,"message":"permission denied for function has_role"}
-- grant usage on schema private to anon;
-- grant execute on function private.has_role(uuid, public.app_role) to anon;
admin      GET /rest/v1/reports       200 [{"id":1,"body":"quarterly numbers"}]
non-admin  GET /rest/v1/reports       200 []
signed out GET /rest/v1/reports       200 []
signed out POST /rest/v1/rpc/has_role 404 {"code":"PGRST202","message":"Could not find the function public.has_role(_role, _user_id) in the schema cache"}
admin      POST /rest/v1/reports      201
non-admin  POST /rest/v1/reports      403 {"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"reports\""}
signed out POST /rest/v1/reports      401 {"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"reports\""}

New functions after a per-schema default

after_4a was created after the Advisors quick reference line, after_4b after the per-schema lines on the Securing your API page as well, and after_4c after the form with no schema, with those per-schema revokes from anon and authenticated still in effect. after_4d_global_only was created on a fresh start of the same stack after the form with no schema alone, so Supabase's per-schema grant to anon and authenticated in public was still there. The table is the owner query from the checklist, cut to these rows and without the empty args column; the after_4d_global_only row is from the fresh start.

-- alter default privileges in schema public revoke execute on functions from anon, authenticated, public;
after_4a  200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
-- alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role;
-- alter default privileges for role postgres in schema public revoke execute on functions from public;
after_4b  200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
-- alter default privileges for role postgres revoke execute on functions from public;
after_4c  401 {"code":"42501","details":null,"hint":null,"message":"permission denied for function after_4c"}
-- fresh start, no per-schema revoke, then only:
-- alter default privileges for role postgres revoke execute on functions from public;
after_4d_global_only  200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]

 proname              | owner    | anon_can_call | signed_in_can_call
 after_4a             | postgres | t             | t
 after_4b             | postgres | t             | t
 after_4c             | postgres | f             | f
 after_4d_global_only | postgres | t             | t

The secret key, and a signed-in only function

my_notes_count was revoked from public and anon and granted to authenticated.

secret key POST /rest/v1/rpc/all_notes_d     200 [{"id":1,"user_id":"00000000-0000-0000-0000-000000000001","body":"a note owned by someone else"}]
signed out POST /rest/v1/rpc/my_notes_count  401 {"code":"42501","details":null,"hint":null,"message":"permission denied for function my_notes_count"}
signed in  POST /rest/v1/rpc/my_notes_count  200 0

Related guides

Sources

Is a function that only reads data still a risk?

Yes, if it reads rows that row level security would hide from the caller. A security definer function reads with its owner's rights, and on Supabase the owner is usually postgres, which bypasses row level security. On a local stack running Supabase CLI v2.119.0, a signed-out call to one returned another user's row, while the same caller's direct select on the table returned an empty list.

Does turning off GraphQL close it?

No. Supabase's Advisors docs say disabling pg_graphql closes the /graphql/v1 route only, and the function stays callable at /rest/v1/rpc/<name>.

Will Supabase's October 30 Data API change close my functions?

Do not count on it. The opt-in SQL in Supabase's announcement changes the defaults for future tables and sequences, and Postgres still grants EXECUTE on each new function to PUBLIC. Supabase staff said in the announcement thread that this "is not handled on purpose as that is the PostgreSQL default". Revoke per function.

Is a security invoker function safe to leave callable?

It runs as the caller, so row level security on the tables it touches still applies. Supabase's Advisors lints 0028 and 0029 skip invoker functions for that reason and leave the risk to the table lints, such as a table with RLS off.