Supabase Edge Functions vs Database Functions: When to Use Each

  • Saturday, October 24, 2026

Supabase Edge Functions vs database functions: which to use for each job, what SECURITY DEFINER actually risks, and how to keep RPCs safe.

Use database functions for logic that reads and writes your own tables, and Edge Functions for anything that calls a third party or holds a secret. The harder half of the Supabase Edge Functions vs database functions decision is security, because a function in the public schema becomes a callable API endpoint the moment you create it.

This guide is for founders and small teams running a product on Supabase, including apps that started life in Lovable, Bolt or Cursor. It covers what each option is for, what SECURITY DEFINER really risks, the platform limits that settle the argument for you, and how to audit the remote procedure calls (RPCs) you have already exposed. It assumes your table policies are already in place; if they are not, start with our walkthrough of finding and fixing data leaks in Supabase tables.

Key takeaways

  • Database functions run inside Postgres and are published through the Data API. Anything in an exposed schema is an endpoint, whether you meant it to be or not.
  • Edge Functions run TypeScript on a Deno runtime outside the database, and are the right home for third-party calls, webhooks and secrets.
  • SECURITY DEFINER is the sharpest edge in the setup: the function runs as its owner, which on Supabase usually means Row Level Security no longer applies.
  • An execute grant decides who may call a function. It does not decide which rows come back, so the ownership check has to sit in the function body.
  • Edge Functions cap CPU time at two seconds per request, so genuinely slow work belongs in a queue rather than in either option.

Supabase Edge Functions vs database functions: the short answer

Supabase splits them by workload: database functions for data-intensive operations, Edge Functions when you want a low-latency endpoint distributed close to users, as set out in the Supabase database functions documentation. The more useful question for a small team is simpler: does the work need to leave Postgres?

Question Database function Edge Function
Where it runs Inside your Postgres instance Deno runtime, distributed globally
Written in SQL or PL/pgSQL TypeScript
How it is called rpc() over the Data API, SQL, or a trigger An HTTPS request or functions.invoke()
Best at Multi-step reads and writes in one transaction Third-party APIs, webhooks, secrets, file work
Weak at Outbound HTTP, long jobs, anything needing a package Transactions, row-heavy aggregation, chatty queries
Main risk A definer function returning rows RLS would block The service-role client bypassing RLS in your handler

A rule that holds up in practice: if the logic is one transaction against your own tables, make it a database function. If it needs the internet, an API key or a library, make it an Edge Function. When it needs both, let the Edge Function call the database function.

How does a database function become a public endpoint?

By existing in a schema the Data API exposes. Supabase publishes everything in public, so writing create function public.recalculate_credits() also creates a callable rpc/recalculate_credits path on your project URL.

The default grants are wider than most teams expect

On an existing project, new objects in public are granted to the anon, authenticated and service_role roles, and new functions receive execute, as documented in Supabase's guide to securing the Data API. The anon role serves requests that carry no user session, so an unauthenticated visitor holding your publishable key can call that function.

Postgres makes this stickier than it looks. Execute is granted to PUBLIC through a global default, and a revoke scoped to a single schema does not remove it. A tidy-looking cleanup can therefore leave the door open, which is why the audit later in this article checks the result rather than the intent.

Publish only the functions you meant to publish

Two habits keep the surface small. Keep internal helpers out of public, since a private schema the Data API does not expose is the cheapest boundary available. Then treat whatever remains as a real API: our guide to designing clean, versioned APIs covers the naming and error-shape decisions that apply to RPCs as much as to REST routes.

What does SECURITY DEFINER actually risk?

It risks handing a caller data they have no right to see. A SECURITY DEFINER function executes with the privileges of the role that owns it rather than the role calling it, and on Supabase a function created in the dashboard or by a migration is owned by postgres, which bypasses Row Level Security.

Sometimes that is the point, for example a function that lets a user join a team by reading an invitations table they cannot otherwise query. The problem is that the keyword is often copied in to make an error go away, and the privilege escalation comes along with it. Supabase's guidance is to prefer security invoker, which is also the Postgres default, and to set the search path whenever you do reach for definer.

Risk one: a grant is not a row filter

Granting execute to authenticated controls who may call a function. It says nothing about which rows come back. A definer function that selects an invoice by an ID passed in as a parameter will return another customer's invoice to any signed-in user who guesses the ID.

With RLS out of the picture, the ownership check has to live in the function body. Treat every parameter as something the caller chose, because it is.

Risk two: an unpinned search_path

Postgres resolves unqualified table and function names using search_path, and the temporary-table schema is searched first by default and is writable by anyone. The PostgreSQL CREATE FUNCTION reference spells out the consequence: without a pinned search path, a caller can create an object that masks the one your function intended to use.

Supabase's answer is set search_path = '' plus schema qualification everywhere, so from invoices becomes from public.invoices. The PostgreSQL manual's own example instead names the trusted schema and puts pg_temp last. Either approach works. Leaving it unset does not.

What a safer definer function looks like

Three things together: an empty search path, an explicit ownership check, and execute privileges narrowed to the roles that need them.

create or replace function public.get_team_invoices(p_team_id uuid)
returns setof public.invoices
language plpgsql
security definer
set search_path = ''
as $$
begin
  if not exists (
    select 1
    from public.team_members
    where team_id = p_team_id
      and user_id = auth.uid()
  ) then
    raise exception 'not a member of this team';
  end if;

  return query
    select * from public.invoices where team_id = p_team_id;
end;
$$;

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

The two grant statements matter as much as the function body. Revoking from public and from anon separately is deliberate, because the global default and the project-level grant are two different things.

What are Edge Functions good at, and where do they stop?

They are good at everything the database should not be doing: calling Stripe, receiving webhooks, talking to an LLM provider, resizing an image, sending email. They stop at runtime limits that are tighter than an ordinary server's.

The limits that shape the design

Supabase publishes these for the hosted platform: 256 MB of memory, a two-second CPU time ceiling per request, a worker wall-clock limit of 150 seconds on the free plan and 400 seconds on paid plans, and a 150-second request idle timeout. The full list is in the Edge Functions limits documentation.

The CPU figure is the one that catches teams out, because it counts actual processor time and excludes time spent waiting on I/O. A function that waits four seconds on a slow third-party API is fine. A function that parses a large CSV in memory is not.

Work that is genuinely slow, such as nightly imports, bulk email or video processing, belongs in a queue with retries instead of a request. Our guide to doing work outside the request covers the retry and idempotency details.

Two defaults worth checking

The first is verify_jwt, which is on by default so the platform validates the token before your handler runs. Switching it off to clear a 401 during development removes that check for everyone, and it is a common reason a function ends up open to the internet. Supabase's guide to securing Edge Functions sets out the auth modes and when each applies.

The second is the admin client. A handler that queries a shared table through the service-role client without filtering by the caller's ID returns every user's rows, because that client bypasses RLS by design. Use the caller-scoped client unless the work has to cross policy boundaries on purpose.

import { withSupabase } from 'npm:@supabase/server@1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
    // ctx.supabase runs as the caller, so RLS still applies.
    // ctx.supabaseAdmin bypasses RLS: filter by ctx.userClaims yourself.
    const { data, error } = await ctx.supabase
      .from('invoices')
      .select('id, amount, status')

    if (error) return Response.json({ error: error.message }, { status: 400 })
    return Response.json({ data })
  }),
}

Which should you use for each job?

Most of these decisions fall out of one question: does the work need to leave the database?

Job Put it in Why
Deduct a credit and write an audit row together Database function One transaction, no network round trips
Return a dashboard aggregate over thousands of rows Database function The data never leaves Postgres
Enforce a rule on every write, wherever it comes from Trigger calling a database function Applies to the SQL editor too, not just your app
Receive and verify a Stripe webhook Edge Function Needs the provider SDK and signature checking
Call an LLM and store the result Edge Function Outbound HTTP and a secret API key
Issue a signed upload URL for a file Edge Function Needs storage credentials the browser must not hold

How do you audit the RPCs you already have?

List every function, find the definers, and check which of those have no pinned search path. This takes a few minutes and usually turns something up.

  1. Run the query below in the SQL editor and read the security_definer and config columns together. A definer function with search_path not set is the first thing to fix.
  2. Open the body of each definer function and confirm it checks who the caller is, not only what they passed in.
  3. Revoke execute from public and anon on everything that is not deliberately open, then grant it back to the roles that need it.
  4. Call each remaining endpoint from a logged-out client holding only the publishable key, and confirm you get an error rather than data.
  5. Move helper functions that your policies and triggers call into a schema the Data API does not expose, and remember to grant execute on them to the roles your policies run as.
select
  n.nspname    as schema_name,
  p.proname    as function_name,
  p.prosecdef  as security_definer,
  coalesce(p.proconfig::text, 'search_path not set') as config
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
order by p.prosecdef desc, p.proname;

Common mistakes

  • Adding security definer to silence a permissions error, which replaces a visible failure with an invisible data leak.
  • Assuming a function is private because the app never calls it. If it is in public, it has a URL.
  • Granting execute to authenticated and treating that as authorisation. It admits every signed-in user, including the ones poking at your API.
  • Disabling verify_jwt during development and shipping it that way.
  • Reaching for the service-role client in an Edge Function because it makes the query work, then forgetting to filter by the caller.
  • Putting a slow import in an Edge Function and discovering the CPU ceiling only when a customer uploads a big file.

Frequently asked questions

Should I ever use SECURITY DEFINER?

Yes, for work that legitimately has to cross a user's own permissions, such as accepting an invitation or writing to an audit table the user cannot read. Use it deliberately, pin the search path, check ownership inside the function, and narrow the execute grant. Everything else should stay on the security invoker default.

Can an Edge Function call a database function?

It can, and that combination is often the cleanest answer. The Edge Function handles the outside world, such as verifying a webhook signature or calling an API, then invokes one database function that applies all the resulting changes in a single transaction. You get third-party access and atomic writes without choosing between them.

Are database functions faster than Edge Functions?

For data work, usually yes, because the logic runs next to the data instead of making several network round trips to fetch it. Edge Functions win on network latency to the user, since they run close to the caller. A query that touches many rows is almost always better inside Postgres.

Is it safe to call an Edge Function straight from the browser?

Yes, provided the function authenticates the caller and keeps its secrets server-side. That is the normal pattern for user-facing functions: the browser sends the session token, the platform validates it, and the handler uses a client scoped to that user. What is never safe is putting a service-role key or a third-party API key in front-end code.

How do I stop a database function being callable by anonymous visitors?

Revoke execute from both public and anon, then grant it only to the roles that should call it. Verify the result by calling the endpoint with just the publishable key and no session. Changing the default privileges for future functions is worth doing at the same time, so the next migration does not reopen the gap.

Getting the split right

Keep data work in the database, keep the outside world in Edge Functions, and treat every function in public as something strangers can call. Then spend twenty minutes on the audit above: definer functions without a pinned search path, missing ownership checks, and grants nobody chose are the three findings that come up again and again.

If you would rather have senior engineers review the setup and build the missing pieces, AlgoSmiths works on fixed-price projects with a working demo every week. Our backend and API engineering work is where this layer gets designed, secured and handed over.

Posted In:
Software & SaaS Solutions

Add Comment Your email address will not be published