Postgres Functions That Skip Ownership Checks: A Common Supabase Bug Class

Postgres Functions That Skip Ownership Checks: A Common Supabase Bug Class
📌

Executive Summary & Key Takeaways

Row Level Security on your Supabase tables does not automatically protect data returned through a database function. A SECURITY DEFINER function runs with the privileges of its creator, typically the postgres superuser, which bypasses RLS entirely. Any role that can call the function, including the anon role that serves requests with no user session, gets back whatever rows the function returns. This is not a bug in Supabase. It is documented behaviour, but it is also one of the most common ways a codebase passes an RLS check on the table and still exposes data it should not.

Table of Contents
  1. Why This Happens
  2. What an Exposed Function Looks Like in Practice
  3. The search_path Problem Alongside This
  4. SECURITY INVOKER Is the Safer Default
  5. How to Restrict Execute Privileges When You Do Need SECURITY DEFINER
  6. How to Check Your Existing Functions
  7. Where This Shows Up in AI-Generated Code
  8. Common questions
  9. Sources

Row Level Security on your Supabase tables does not automatically protect data returned through a database function. A SECURITY DEFINER function runs with the privileges of its creator, typically the postgres superuser, which bypasses RLS entirely. Any role that can call the function, including the anon role that serves requests with no user session, gets back whatever rows the function returns. This is not a bug in Supabase. It is documented behaviour, but it is also one of the most common ways a codebase passes an RLS check on the table and still exposes data it should not.

Why This Happens

In Postgres, a function created with SECURITY DEFINER runs as the user who owns the function, not the user who calls it. When you create a function through the Supabase dashboard or through a migration, it is owned by the postgres role, which is a superuser. Superusers bypass Row Level Security. The result: your carefully written RLS policies are invisible to that function.

The Supabase documentation states this clearly: 'A security definer function can return rows the caller isn't allowed to read.' It runs with its creator's privileges. A function created in the Dashboard or by a migration is owned by postgres, which bypasses Row Level Security.

The second half of the problem is execute privileges. By default, any role can run a function unless you revoke execute. That includes the anon role, which is assigned to API requests that carry no bearer token. This means an unauthenticated user can call a SECURITY DEFINER function and receive data that your table policies would have blocked.

What an Exposed Function Looks Like in Practice

Consider a function written to look up an invoice by ID. The invoices table has RLS enabled with a policy that only returns rows where user_id matches the authenticated caller. The function is created as SECURITY DEFINER to allow it to perform an aggregation or join that requires elevated access. The developer tests it while logged in as a user and it returns the correct result. What they do not test is calling the same function as anon with a different user's invoice ID. The function returns that row. The RLS policy never ran.

This is the pattern we test for in Mandizo, a client product with invoice management, user roles, and public-facing RPC endpoints. SQL tests in the supabase/tests directory verify that calling an RPC endpoint as anon returns nothing it should not, and that calling it as an authenticated user only returns rows belonging to that user. Without explicit tests against the anon and authenticated roles at the function level, this class of issue does not surface in normal development.

The search_path Problem Alongside This

SECURITY DEFINER functions carry a second risk that is separate from ownership. When a function runs as a superuser without an explicit search_path, it inherits the session's search path. An attacker who can create a function or a schema with the same name as a table can redirect the function's queries. The docs say that when you use security definer you must set the search_path, and recommend an empty string and qualifying every table reference with its schema name:

  • Bad: SECURITY DEFINER with no search_path setting and unqualified table names like FROM invoices
  • Good: SECURITY DEFINER SET search_path = '' with qualified references like FROM public.invoices
  • Note: an empty search_path limits damage but does not change who can call the function or what RLS does

Pinning the search_path and adding SECURITY DEFINER together is the minimum required for a function that genuinely needs superuser privileges. But the better question is whether the function needs SECURITY DEFINER at all.

SECURITY INVOKER Is the Safer Default

The Supabase docs say to prefer security invoker, which is also the default. A SECURITY INVOKER function runs as the calling user, which means RLS policies apply normally. The calling user's row-level policies restrict what data the function can read. If the function does need to aggregate data from multiple users' rows for a legitimate purpose, that is when you consider SECURITY DEFINER, and only then, with explicit privilege restrictions.

For most application functions, particularly those that look up a single user's data, read a configuration, or perform per-user actions, SECURITY INVOKER is correct. The pattern of using SECURITY DEFINER as a default, or without knowing why, is where the problem begins.

How to Restrict Execute Privileges When You Do Need SECURITY DEFINER

If a function genuinely requires SECURITY DEFINER, the execute privilege must be restricted. The Supabase documentation gives two approaches: revoking per function, or changing default privileges.

Option one is to revoke on a case-by-case basis for each function you want to protect:

  • REVOKE EXECUTE ON FUNCTION public.your_function FROM public;
  • REVOKE EXECUTE ON FUNCTION public.your_function FROM anon;
  • Then GRANT EXECUTE ON FUNCTION public.your_function TO authenticated; (or to a specific role)

Option two is to change the default for all future functions in the schema, then grant back for the ones that need it:

  • ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM public;
  • ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM anon, authenticated;
  • Then grant individually: GRANT EXECUTE ON FUNCTION public.specific_function TO authenticated;

The second approach is more defensive but requires you to explicitly grant each function you want to be callable. For a new project, it is the one we would use. For an existing codebase, audit all functions before applying the blanket revoke, or you will break existing functionality.

How to Check Your Existing Functions

The fastest way to see which functions in your Supabase project are SECURITY DEFINER is to query the information schema. The following query returns functions in the public schema with their security type:

  • SELECT routine_name, security_type FROM information_schema.routines WHERE routine_schema = 'public' AND routine_type = 'FUNCTION';
  • Any function where security_type is 'DEFINER' needs a privilege review.
  • Cross-check against which roles have EXECUTE grants on each function.

Beyond the information schema query, the real test is behavioural. Write a short test that calls the function as anon and verify it returns nothing it should not. Then call it as a different authenticated user and verify it returns only that user's rows. Checking what a function is declared to do is less reliable than checking what it actually returns under realistic calling conditions. This is the approach we took in Mandizo's SQL test suite and it exercises exactly these anon and cross-user cases.

Where This Shows Up in AI-Generated Code

AI coding tools, when asked to write a Supabase function, can generate SECURITY DEFINER without being asked, in our experience, because it is a common way to get past permission errors during development. A developer encounters a permission denied error, the AI suggests adding SECURITY DEFINER, the error goes away, and the function ships without the privilege restriction being added afterwards.

If your codebase includes AI-generated Supabase migrations or functions, the information schema query above is a fast way to identify every function that needs review. The Supabase RLS post covers the table-level RLS side of the same pattern.

If you would like a review of your Supabase setup, including function privileges and RLS coverage, the free code review tool checks common misconfigurations automatically. For a deeper look at how your functions behave at the role level, you can request a technical review from the same page.

Common questions

Does Row Level Security apply to Supabase database functions?

Not for SECURITY DEFINER functions. They run with their creator's privileges, typically the postgres role, which bypasses RLS, so they can return rows the caller isn't allowed to read. SECURITY INVOKER functions run as the calling user, so RLS applies normally.

How do I stop anonymous users calling my Supabase function?

Revoke execute from public and anon, then grant it only to the roles that need it, either per function or by changing default privileges for the schema. On an existing codebase, audit all functions before a blanket revoke or you will break existing functionality.

Can someone review my Supabase function privileges and RLS?

Zovintra's free code review tool checks common misconfigurations automatically, and a technical review can be requested from the same page. To self-check first, list DEFINER functions via information_schema.routines, then call each as anon and as a different authenticated user.

Sources

TECHNICAL CONSULTATION

Building something similar?

Talk to our engineering team about your architecture, roadmap, and delivery timeline.

Request a Technical Review →

Related Reading

← Back to all posts