DEV Community

Ohad Krispin
Ohad Krispin

Posted on Originally published at viberaven.dev AI-assisted

In 47% of 512 Lovable apps, the anon key can call a security definer function

242 of 512 public Lovable projects (47.3%) define at least one security definer function in public that a signed-out caller with the anon key can run at /rest/v1/rpc. We read the functions in 18 of them by hand. In 7, a signed-out caller could create or change rows that belong to other users or to the app. In 2, the function checked the caller first.

These are repos, not live databases. No database was queried and no function was called.

I build VibeRaven, an open-source repo check for Vercel + Supabase apps. The scan behind these numbers ran on it.

Why a security definer function matters

A security definer function runs with its owner's rights, so RLS does not limit what it touches. On Supabase, Postgres grants EXECUTE on every new function to PUBLIC, and Supabase also grants it to anon and authenticated in public. Unless a migration takes that away, anyone holding the anon key can call the function over the API.

Inside the function, auth.uid() is null for a signed-out caller. A function that checks it first stops there. A function that takes a user id as an argument and acts on it with no check does whatever the caller asks.

What we found

Out of 512 public Lovable projects with their own Supabase migrations, counting functions from migrations dated before May 30, 2026:

  • 47.3% (242) have at least one security definer function in public that no migration closes to anon. Trigger functions are not counted, because nothing can call them over the API.
  • 13.1% (67) have only role-check helpers such as has_role or is_admin. With one, a signed-out caller can ask whether a given user id is an admin. has_role alone appears in 117 repos.
  • 34.2% (175) have at least one function that is not a role check.
  • At least 7.8% (40) grant EXECUTE on such a function to authenticated and never revoke it from anon.
  • At least 2.5% (13) revoke EXECUTE from PUBLIC and stop there, which leaves the separate grant Supabase gives anon.

May 30 is the cutoff because Supabase began changing default grants for new projects then, and we did not test what a new project grants on functions.

What a signed-out caller can do: 18 repos read by hand

We drew 20 repos at random from the 195 with a callable function other than a role check, cloned each at its last commit before the scan, and read every such function. One repo was no longer public, and one had its functions in a migration written after May 30, 2026. That left 18. Each repo is counted once, in the first row it fits.

What a signed-out caller can do Repos Examples, paraphrased, projects withheld
Create or change rows that belong to other users or to the app 7 Update any user's profile by id. Issue certificates and badges to any user. Post into any user's notification feed. Change any user's usage credits, where a negative amount refunds them. Set the admin password for a data import. Create active employee accounts. Mint discount codes for any owner.
Read data with no check in the function 4 Ad spend totals, judges' score statistics, a user id looked up by username, and an admin login check with its password written in the migration.
Only low-impact writes 5 Audit log rows, rate-limit counters, cleanup of expired rows, badges a user already qualifies for.
Nothing 2 The function changes only the caller's own row, found by auth.uid().

A check that looks safe and lets the call through

One repo guarded its function like this:

if p_user_id != auth.uid() then
  raise exception 'not allowed';
end if;
Enter fullscreen mode Exit fullscreen mode

For a signed-out caller auth.uid() is null, so the comparison is null, and plpgsql treats a null condition as false. The raise never runs. In that repo the function went on to award a badge to any user who met its requirements. A second repo has a role check of the same shape.

if auth.uid() is null then raise exception ..., which other repos in the sample use, does stop the call.

Two grant lines that take nothing away

In the hand-read sample, 3 of the 7 repos where a signed-out caller could change data had granted that same function to authenticated:

-- reads as "signed-in users only"; anon can still call it
grant execute on function public.set_import_password(text) to authenticated;

-- reads as "server only"; anon and authenticated can still call it
revoke execute on function public.set_import_password(text) from public;
grant execute on function public.set_import_password(text) to service_role;
Enter fullscreen mode Exit fullscreen mode

A grant only adds a role. anon keeps the EXECUTE it got from Supabase's default privileges. And revoke ... from public removes the Postgres default but leaves the grants Supabase adds for anon and authenticated. Supabase's Advisors docs revoke from anon and PUBLIC together for this reason.

Check your project and close what anon should not call

Run this in the Supabase SQL editor. It lists the security definer functions in public and whether a signed-out or signed-in caller can run them:

select p.proname,
       pg_get_function_identity_arguments(p.oid) as args,
       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_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and p.prosecdef
  and p.prorettype <> 'pg_catalog.trigger'::regtype
order by p.proname;
Enter fullscreen mode Exit fullscreen mode

If only your server calls a function, revoke all three in one statement:

revoke execute on function public.your_function(uuid) from public, anon, authenticated;
Enter fullscreen mode Exit fullscreen mode

A helper your policies call, such as has_role, needs a different fix: move it to a schema the API does not expose and keep EXECUTE for the roles whose policies use it.

The repo check flags these in migrations before deploy. On a fixture with one function, add_credits(p_user_id uuid, p_amount integer), security definer and granted to authenticated, npx -y viberaven@1.7.8 check printed this excerpt (folder shortened; other findings omitted):

viberaven check ยท ~/credits-app

๐ŸŸก Security definer function callable by the Data API roles  (security_definer_function_executable)
   supabase/migrations/20260301000000_credits.sql:1
   public.add_credits(uuid, integer) at supabase/migrations/20260301000000_credits.sql:1 is security definer, so it runs with its owner's rights and RLS does not limit what it touches. No migration revokes EXECUTE from public, anon, authenticated, so anyone holding the anon key can call it at /rest/v1/rpc/add_credits. It is granted to authenticated on purpose, so this fix only closes it to anonymous callers. If only the server calls it, add: revoke execute on function public.add_credits(uuid, integer) from public, anon; Fix: revoke execute on function public.add_credits(uuid, integer) from public, anon;
   Guide: https://viberaven.dev/supabase-security-definer-function-anon
Enter fullscreen mode Exit fullscreen mode

It reads the migrations, not the live grants, so run the query above against the project too.

Method

Public GitHub repos whose package.json includes lovable-tagger, Lovable's build tag, found through GitHub code search and kept when they had a supabase/migrations folder, read with VibeRaven 1.6.1 on October 3, 2026. The check replays the migrations in order and flags a security definer function in public, other than a trigger function, unless EXECUTE ends up revoked from public, anon and authenticated. Role-check helpers were sorted by name prefix (has_, is_, can_ and a few similar). The hand reading was done on October 19, 2026.

What we did not measure

  • Whether any function is callable today. A grant changed in the dashboard would not show up, and a repo is not always what is live.
  • Functions from migrations after May 30, 2026. They are left out of every number above.
  • Every function. The scan lists up to 10 functions per repo, so the grant-to-authenticated and revoke-from-PUBLIC counts can be higher. The 67 role-only repos are complete.
  • A random sample. It comes from GitHub search, and public repos lean toward side projects. Read the 18-repo split as examples, and do not scale it to all 242.

Sources

Full post with the method notes: viberaven.dev.

Written with help from Claude and Codex. The scan, the hand reading and the numbers are ours.

Top comments (0)