Supabase Security Definer Functions Bypass RLS: The Fix (2026)

Our own Supabase tutorials had this bug. Three of them create SECURITY DEFINER functions in the public schema with no search_path and no REVOKE, which is exactly what Supabase's linter flags as 0028_anon_security_definer_function_executable. We'd copied the pattern from the same places your AI coding tool did.

The ones in our tutorials turned out to be harmless, for a reason worth understanding. The same pattern with one parameter changed is not.

TL;DR

A SECURITY DEFINER function runs as its owner, postgres, so RLS doesn't filter anything it touches. Postgres lets every role execute a new function by default, and Supabase exposes public functions at /rest/v1/rpc/. So anyone holding your anon key can call it. Find yours with the query below, then revoke from public and anon (anon alone does nothing), switch to SECURITY INVOKER, or move RLS helpers to a private schema. Don't revoke from authenticated on a function your policies call; that breaks the table.

What we tested

We rebuilt the relevant parts of a Supabase project on stock PostgreSQL 16.15: anon and authenticated roles, an auth.uid() that reads the JWT subject, and Supabase-style default privileges that grant new functions in public to both roles. Then one profiles table with RLS on and a policy that lets a user see only their own row.

This is a reproduction, not a live Supabase project, so treat the exact error text as Postgres's. The privilege behaviour is plain Postgres, which is what Supabase runs.

StepAs anonResult
select count(*) from profilesDirect table read0 rows (RLS working)
get_email_invoker(bob_id)SECURITY INVOKER functionnull (RLS applies)
get_email_definer(bob_id)SECURITY DEFINER functionbob@example.com
add_credits(bob_id, 1000000)SECURITY DEFINER updatecredits: 5 to 1,000,005
revoke ... from anon, call againstill bob@example.com
revoke ... from public, anon, call againpermission denied
Same function, signed in as Aliceauthenticatedstill bob@example.com

Five things fall out of that table. RLS was on the whole time. The invoker version of the same function behaved correctly. A definer function that writes is worse than one that reads. Revoking from anon alone is a no-op, because anon inherits EXECUTE from PUBLIC. And a fix for lint 0028 still leaves every signed-up user able to call it, which is lint 0029.

Why this happens

Supabase's database functions guide says it in three sentences, and they're worth reading together:

"A security definer function can return rows the caller isn't allowed to read."

"A function created in the Dashboard or by a migration is owned by postgres, which bypasses Row Level Security."

"Any role can call it by default, including anon."

Add the fact that functions in an exposed schema are callable through the REST API and you get the full chain: a POST to /rest/v1/rpc/get_email_definer with the anon key in the header runs your SQL as the database owner.

curl -X POST "https://YOUR_PROJECT.supabase.co/rest/v1/rpc/get_email_definer" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"uid": "any-user-id"}'

AI tools reach for SECURITY DEFINER for a legitimate reason: it's the standard fix for infinite recursion when a policy on team_members needs to query team_members. The keyword makes the error go away, and nothing else changes visibly, so nobody looks again.

The October 30 Data API change doesn't cover this

Supabase is applying its "tables not exposed automatically" setting to all existing projects on October 30, 2026. The SQL in that changelog revokes default privileges on tables and sequences. It doesn't mention functions, and the PUBLIC grant on EXECUTE comes from Postgres, not from Supabase. Don't assume the change closes your RPC surface.

The safe kind and the dangerous kind

Not every definer function is a hole. The test is simple: does the function decide whose data to touch, or does the caller?

Here's the shape from our own RLS tutorial. It's fine:

create function is_team_member(team_id uuid) returns boolean as $$
  select exists (
    select 1 from team_members
    where team_members.team_id = $1
      and team_members.user_id = auth.uid()
  );
$$ language sql security definer;

An anonymous caller has no auth.uid(), so it gets false for every team. A signed-in caller can only ask about themselves. The function bypasses RLS internally, but its answer is pinned to the caller's identity, so it leaks nothing they couldn't already know.

Now the dangerous shape, which is what you get when you ask an AI to "write a function that gets a user's email" or "add credits after payment":

create function get_user_email(uid uuid) returns text as $$
  select email from profiles where id = uid;
$$ language sql security definer;

The caller chooses uid. Nothing checks it against auth.uid(). That's an IDOR with the database owner's privileges, reachable from a browser. Anything that writes (credits, roles, subscription status) on a caller-supplied id is the same bug, and it's the worse version.

If a definer function takes a user id, an org id, or an amount as a parameter and doesn't compare it against auth.uid() inside the body, assume anyone on the internet can call it with any value.

Step 1: Find every definer function anon can call

Run this in the Supabase SQL Editor. It lists every SECURITY DEFINER function in public, who can execute it, and whether its search_path is pinned:

SQL Editor: audit definer functions
select
  p.proname as function,
  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,
  p.proconfig as settings
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and p.prosecdef
order by 1;

Anything with anon_can_call = true is reachable with your public key. A null settings column means no search_path pin. The Security Advisor in the dashboard reports the same thing as lints 0028 and 0029, but this query also shows you the arguments, which is what you need for the safe-or-dangerous test above.

Trigger functions (the returns trigger kind, like the usual handle_new_user()) will show up too. Postgres refuses to call those directly, so they're not an RPC risk, but pin their search_path anyway.

Step 2: Pick the right fix for each one

1

It doesn't need to bypass RLS: switch to invoker

Most "get my stuff" functions don't need definer at all. RLS will do the filtering for you:

alter function public.get_user_email(uuid) security invoker;

In our test, the invoker version returned null for someone else's row, which is the behaviour you wanted all along.

2

Only your server calls it: revoke from public, anon and authenticated

For functions your backend or an edge function calls with the service role key, take them away from everyone else:

revoke execute on function public.add_credits(uuid, int)
  from public, anon, authenticated;
grant execute on function public.add_credits(uuid, int) to service_role;

All three roles, in one statement. Revoking from anon alone changed nothing in our test, and revoking from public, anon still left signed-in users able to call it.

3

An RLS policy calls it: move it to a private schema

This is the case where the obvious fix bites. Policies execute their functions as the querying user, so if you revoke EXECUTE from authenticated on a helper your policy uses, every select on that table fails. We tried it: permission denied for function owns, on a table that had worked a moment earlier.

The fix is keeping the helper out of the API entirely:

create schema if not exists private;
grant usage on schema private to authenticated;

create function private.is_team_member(team_id uuid)
returns boolean
language sql
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.team_members
    where team_members.team_id = $1
      and team_members.user_id = auth.uid()
  );
$$;

Then point the policy at private.is_team_member(team_id) and scope it to authenticated. In our test the policy kept working, and anon had no usage on the private schema. Skip the to authenticated and anonymous queries on that table stop returning zero rows and start returning a permission error instead, because Postgres still evaluates the policy for them. Don't add private to the Data API's exposed schemas.

4

Pin the search_path on every definer function you keep

Supabase's guide is direct about it: "When you use security definer, you must set the search_path." Set it to empty and schema-qualify every name in the body (public.profiles, not profiles):

alter function public.handle_new_user() set search_path = '';

That one breaks if the body uses unqualified names, so update the body in the same migration.

Step 3: Stop new ones from shipping open

Postgres grants EXECUTE to PUBLIC on every function it creates, so fixing today's list doesn't help next week's migration. Change the default, and note that it takes two statements:

-- the PUBLIC grant is global, so it has to be revoked globally
alter default privileges for role postgres
  revoke execute on functions from public;

-- the anon/authenticated grants are per-schema
alter default privileges for role postgres in schema public
  revoke execute on functions from anon, authenticated;

We first wrote this as a single in schema public statement covering all three roles. It ran without error and changed nothing: new functions were still callable by both roles. Postgres documents why: per-schema default privileges are added to the global ones, so you can't take away per-schema what was granted globally. Run both, then create a test function and check it with the audit query.

After that, any function you want callable from the browser needs an explicit grant execute ... to authenticated, which is the point. You decide per function instead of inheriting "everyone."

Add one line to your AI tool's rules file: "Never use SECURITY DEFINER on a function in the public schema. If a policy needs to bypass RLS, put the helper in the private schema with set search_path = ''." It's the cheapest fix on this page.

What we changed in our own posts

The is_team_member, is_team_admin and is_admin examples in our RLS policies tutorial and Supabase security guide now live in a private schema with set search_path = ''. They were never exploitable, because each one keys off auth.uid(), but they tripped the same lints yours will, and a tutorial shouldn't teach the pattern that becomes dangerous the moment someone adds a parameter.

Does a SECURITY DEFINER function bypass RLS in Supabase?

Yes, in practice. It runs with its owner's privileges, and a function created in the dashboard or a migration is owned by postgres, which bypasses Row Level Security. Anything the function body reads or writes is not filtered by the caller's policies.

Can the anon key call my Supabase database functions?

By default, yes. Postgres grants EXECUTE on every new function to PUBLIC, and Supabase's defaults also grant it to anon and authenticated. Any function in an exposed schema is reachable at /rest/v1/rpc/function_name with just the anon key.

Does Supabase's October 30, 2026 Data API change fix this?

No. The SQL in Supabase's changelog revokes default privileges on tables and sequences only. It doesn't touch functions, and the PUBLIC grant on EXECUTE comes from Postgres itself, so new functions stay callable by anon.

How do I fix lint 0028 anon_security_definer_function_executable?

Revoke EXECUTE from public and anon (revoking from anon alone does nothing, because it inherits the PUBLIC grant), switch the function to SECURITY INVOKER, or, for an RLS helper, move it to a schema the Data API doesn't expose. Fix 0029 for authenticated at the same time.

Why did my RLS policy start failing after I revoked EXECUTE?

Policies call functions as the querying user. If a policy uses your helper and you revoke EXECUTE from authenticated, every query on that table fails with permission denied for function. Move the helper to a private schema instead of revoking.

Check What Your Anon Key Can Reach

CheckYourVibe scans your deployed app for exposed Supabase keys, open tables and the endpoints your RLS never sees.

How-To Guides

Supabase Security Definer Functions Bypass RLS: The Fix (2026)