Supabase Security Definer Functions: The Exposure You Might Miss

In Supabase, public-schema functions are callable over the API — and SECURITY DEFINER ones bypass row-level security. What our audit found in 117 such functions, the real damage paths, and the grants and rules that close them.

AAAayush AdhikariSeptember 24, 2026 6 min read

Supabase security definer functions run with the privileges of their owner — usually a role that bypasses row-level security — and any function in the public schema can be called through the Data API at /rest/v1/rpc/<name> by roles that have EXECUTE on it. Postgres grants EXECUTE to PUBLIC by default, anonymous role included. So a definer helper taking an organization id, URL or user id can bypass every policy you wrote. Revoke execute by default, grant it back deliberately, and never trust a tenant argument.

Why definer functions exist

Row-level security policies often need facts the caller can't read directly. A policy on requests needs the caller's organization, which lives in profiles, which has its own policies — reading it inside a policy can recurse. The standard answer is a small security definer function:

create or replace function public.current_user_org()
returns uuid language sql stable security definer set search_path = public
as $$ select organization_id from public.profiles where id = auth.uid() $$;

It runs as its owner, reads the profile without triggering policies, and returns only the caller's own organization. That's fine. The problem is everything else that ends up security definer.

The exposure

Two defaults combine:

  1. PostgREST exposes functions in exposed schemas as RPC endpoints. Anything in public can be called with a POST to /rest/v1/rpc/function_name — with the public anon key that ships in every browser.
  2. Postgres grants EXECUTE on new functions to PUBLIC. Unless you revoke it, anonymous and signed-in callers can run them.

Supabase's row-level security documentation warns that security definer functions owned by roles like postgres bypass RLS and should never be created in an exposed schema if they shouldn't be callable. Many projects — ours included — did it anyway, because it's the path of least resistance.

What our audit found

In a September 2026 security audit of LetRelay, 117 security definer functions in public were executable by anyone, signed in or not. Most were harmless helpers. These were not:

Function What it allowed
match_knowledge(…, p_org) Reading any organization's knowledge base by passing its id
dispatch_outbound(p_org, …) Sending forged messages through another organization's Slack/Discord webhooks
Webhook send helpers taking the webhook as a parameter An open HTTP relay out of the database's network — the URL safety check on the table never ran
push_notification(user, …) Putting arbitrary text in any user's notification bell — a phishing channel
seed_org_defaults(p_org, …) Writing teams and categories into another organization
A storage watchdog Deleting a share of all direct messages when called
Cron workers Processing every organization's queue, callable by anyone

A similar bug existed in a usage meter: ai_gate(p_org) let any user burn another organization's monthly AI allowance. None of these were exotic; each was a reasonable function written for server or cron use, left callable by default.

The fix, in three layers

1. Close by default, reopen on purpose

-- Nobody but the intended roles
revoke execute on all functions in schema public from public, anon;
grant execute on all functions in schema public to authenticated, service_role;
 
-- Functions created later start closed to PUBLIC and anon
alter default privileges for role postgres in schema public
  revoke execute on functions from anon;
 
-- Then remove internal machinery from signed-in users too
revoke execute on function public.dispatch_outbound(uuid, text, jsonb) from authenticated;
revoke execute on function public.push_notification(uuid, uuid, text, uuid, uuid, text, text) from authenticated;
-- …one line per internal function

After this, anonymous callers can execute exactly one function in LetRelay's schema — the one the public blog's read policy needs. Two things keep working without explicit grants: triggers fire without an EXECUTE check on the trigger function, and pg_cron jobs run as the owner. Internal machinery can therefore be closed to every API role and still do its job.

Note the trap in the default-privileges step: it closes new functions to anon, but Supabase's schema defaults still grant them to authenticated. A new internal helper must be revoked from authenticated explicitly — so make "revoke" part of the template for writing one.

2. Never trust a tenant argument

A definer function that takes p_org must check the caller's right to it — or not take it at all:

-- the caller's own organization, whatever they pass (service role may name any)
and ke.organization_id = case
  when auth.role() = 'service_role' then coalesce(p_org, public.current_user_org())
  else public.current_user_org()
end

Same for user ids, URLs and anything else that selects what the function acts on. If the argument lets a caller point the function at someone else's data, the function is an authorization bypass.

3. Prefer invoker functions

Many functions don't need to be definers. A reporting function that reads requests can run as the caller (security invoker, the default), and RLS will scope its results. LetRelay's analytics functions work that way — see an analytics dashboard with plain Postgres. Use security definer only when a function must read something the caller can't, and keep those functions tiny.

Always pin search_path

PostgreSQL's documentation on writing SECURITY DEFINER functions safely warns that a function using an unqualified name can be tricked into calling an attacker's object if the caller controls the search path. Pin it on every definer function:

create or replace function public.x() … security definer set search_path = public

Or schema-qualify every reference. Supabase's database advisors flag functions without a fixed search path.

Test it like an attacker

Add a check to your test suite that runs as the anonymous role and as a signed-in user from another organization, and calls every exposed function with hostile arguments: another organization's id, another user's id, an internal URL. Assert that nothing is returned and nothing changes. Also list every function executable by anon and compare it to an allowlist; a new function appearing there should fail the build.

LetRelay now runs an exposure check on every verification pass for exactly this. The broader multi-tenant testing approach is in the multi-tenant RLS guide.

A checklist for every new function

  • Does it need to be security definer? If not, leave it invoker.
  • set search_path is pinned.
  • Arguments can't select another tenant's or user's data.
  • revoke execute … from public, anon — and from authenticated if it's internal.
  • Granted only to the roles that call it.
  • Covered by the exposure test.

FAQ

Can anyone call my Supabase database functions?

Functions in an exposed schema like public are reachable through the Data API's RPC endpoint. Whether a caller can run one depends on EXECUTE privileges, which Postgres grants to PUBLIC by default — so unless you revoke them, anonymous callers can.

Do security definer functions bypass RLS?

Yes, when owned by a role that bypasses RLS (such as postgres). They see and change data regardless of the caller's policies, so they must enforce their own checks.

Do triggers need EXECUTE permission?

No. Trigger functions fire without an EXECUTE check on the calling user, so you can revoke execute from API roles and triggers keep working. pg_cron jobs run as their owner.

How do I find exposed functions?

Query the function privileges for the anon and authenticated roles in your schema, compare against an allowlist, and fail your tests when an unexpected function appears.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading