Supabase’s automatic API generation from your database schema makes web app development incredibly fast. Instantly, your tables are exposed over REST and GraphQL API, and you can start building rich features without thrashing out any backend code or configuration besides the tables themselves.
But is it wise to launch a product with data sitting in public schemas, when it can be scraped so easily by curious developers or even competitors?
Take a photo‑sharing app: there’s a table with all events, and a table with all photos. With events and photos both in the public schema, someone could write a custom query to SELECT * from events, then loop through each event fetching all photos by event ID — effectively scraping the entire system in minutes. Or, if they don’t care about the relationship, they could just pull every row from the photos table directly.
Ideally, the access restriction should only allow “get all events where author_id = X or get a single event where id = X” — effectively making the event ID an unknown token that the host shares only with trusted parties.
RLS alone wouldn’t prevent this kind of data exposure.
Small but important clarification: RLS can absolutely stop users reading rows they should not read, if your policies are tight enough. What it does not do is remove the shape of your API. If the table or function is on the PostgREST surface, people can still see what they can try to call, then hammer away at whatever your policies allow.
Solution, cobble part of what makes Supabase fast to develop
There is a well-recognised limitation to relying only on RLS in the Supabase public schema: while RLS restricts which rows users can fetch, anyone with the anon key can still construct complex queries — such as listing all events and scraping associated photos — subject only to your grants and RLS rules. This means that unless your policies are highly specific, broad data scraping can remain possible.
To prevent public users from running unrestricted queries or scraping your data, the recommended best practice is:
-
Move sensitive tables (like events, photos) to a private, non-exposed schema.
- Move your tables with:
ALTER TABLE public.events SET SCHEMA private;and similarly forphotos.
- Move your tables with:
-
Do NOT expose the private schema to Supabase Data API clients.
-
In your Supabase Project API settings, ensure
privateis not listed under “Exposed schemas.” Only the schemas you actually want callable through the generated API should be there.
-
-
Expose only controlled access through database functions or a backend API.
-
Write stored procedures (Postgres functions) that internally fetch only authorized rows (e.g., only events/photos where author_id=X or id=X), validating logic inside the function.
-
Grant EXECUTE permission on these functions to anon/authenticated roles only when those users really should call them, but never SELECT permissions on private tables.
-
-
RLS is still necessary, but becomes a backup layer.
-
Inside the private schema, use RLS to lock down the tables as usual. But since direct client access is blocked, scraping now has to go through the narrow routes you chose to expose.
-
What about public functions?
This is where people can get a bit muddled. A function in the public schema is not automatically executable by every random client just because it exists. Postgres grants still matter. You can revoke EXECUTE from public, anon and authenticated, then grant it only to service_role if that function is only meant to be called from a trusted backend or Edge Function.
revoke execute on function public.do_sensitive_work(uuid) from public;
revoke execute on function public.do_sensitive_work(uuid) from anon;
revoke execute on function public.do_sensitive_work(uuid) from authenticated;
grant execute on function public.do_sensitive_work(uuid) to service_role;
So yes, public schema functions are controllable. But putting backend-only operations in public still feels like leaving sharp tools on the kitchen counter. You can make it safe with grants, but it is easier to reason about if browser-callable functions live in one API schema and backend-only functions live somewhere else.
For example, you might use api for functions the browser is allowed to call, and api_edge for functions only Edge Functions call with the service role key. The important part is not the name. The important part is that the grants match the intent.
Edge Functions do not magically escape the Data API
Another easy trap: if your Edge Function uses supabase-js and calls .rpc(), that call still goes through the Supabase Data API / PostgREST surface. The code is server-side, yes, but the schema still has to be exposed in the Data API settings or PostgREST cannot see it.
The service role key bypasses RLS. It does not bypass the need for the schema to be exposed to PostgREST. If you try to call an RPC in a schema that is not exposed, you get schema errors before your nice grants and policies even get a say.
That means an api_edge schema used through supabase-js has to be exposed, but its functions should only grant EXECUTE to service_role. Public clients may be able to discover that the API shape exists, but they should not be able to run the function.
If you do not want those functions on the generated API surface at all, then do not call them through supabase-js. Connect from the Edge Function to Postgres directly using a database connection string and a server-side Postgres client. More boilerplate, less magic, less surface area.
RPC is safer when it is narrow
RPC is not fairy dust. A function called get_every_photo_for_every_event() is just a scraper endpoint with a hat on. The benefit comes from making the operation narrow: get one event, get my events, add one photo, restore one revision, save one thing if the version still matches.
That is the useful middle ground. You still get Supabase speed. You still get generated clients. But you stop giving the browser a general-purpose handle on your internal data model.
Why is this better than RLS alone?
-
It removes direct table/field visibility and listing from the client API, reducing the attack surface and cutting down schema enumeration.
-
All access must go through a limited interface (function/Edge API/backend route) where you fully control parameters and validate all queries.
Says who
Supabase docs, and expert best practices recommend schema isolation and function-limited access for secure production applications. This pattern is widely used to avoid broad fetches and scraping in SaaS, financial, and multi-tenant platforms.
The boring-but-important version is: exposed schemas define the generated API surface; grants decide who can touch the objects; RLS decides which rows they get once they can touch them. You want all three lined up, not just one of them doing all the work.
Cost and benefit
Then why not just create a 5GBP DO droplet with MySQL, PHPMyadmin and a single PHP file to return the query results as JSON?
Good question.
First thing that comes to mind is that you probably don’t want your database running in your VM. Managed databases sometimes cost almost as much if not more than Supabase. Then managing deployment of updates to the API, do you install git, SSH into the server and pull updates? FTP the new php file? shudder. Or subscribe for 15 USD a month to a deployment service to push the master branch into place every 3 months? Versioned configuration-as-code anyone? No?
And you’ve now spent a day thinking about infra and not shipped anything.
So no, I am not saying throw Supabase in the bin. I am saying the default public schema is brilliant for getting moving, but it probably should not be the final boundary for everything your production app can do.