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.
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:
- PostgREST exposes functions in exposed schemas as RPC endpoints. Anything in
publiccan be called with a POST to/rest/v1/rpc/function_name— with the public anon key that ships in every browser. - Postgres grants
EXECUTEon new functions toPUBLIC. 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 functionAfter 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()
endSame 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 = publicOr 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_pathis pinned. - Arguments can't select another tenant's or user's data.
-
revoke execute … from public, anon— and fromauthenticatedif 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
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.
Keep reading
LLM Tool Calling for Internal Assistants: Design Choices
How to give an internal AI assistant tools — reading workspace data with the user's permissions, formatting results for the model, native function calling vs a routing step, limits on how many tools run, and answers that stay inside the data.
IT Asset Management for Small Companies: A Lightweight Approach
A practical IT asset management setup for small companies — what to track, one record per asset, lifecycle states from purchase to disposal, linking assets to people and requests, and the security reasons it matters.
Scheduled Jobs in Supabase with pg_cron: A Practical Guide
How to run scheduled jobs inside Postgres on Supabase with pg_cron — idempotent schedules in migrations, catch-up logic, locking down job functions, outbound HTTP with pg_net, monitoring runs, and the free-tier pause.