DEV Community

Cover image for Supabase exposed schemas: what the list opens up
Denys Zaichenko for Denys

Posted on Originally published at madsalt.tech

Supabase exposed schemas: what the list opens up

TL;DR. The Exposed schemas list in Supabase decides which schemas the Data API serves to anyone holding your public key. The default is public and graphql_public. Every table, view and function in a schema on that list needs its own grants and RLS, so each extra schema is more surface to defend. Keep the list short and check it first.

I built a free Supabase security checker and the Exposed schemas list is one of the first things it reads. When someone asks me how secure their Supabase project is, this setting is where I start.

What does "exposed" mean in Supabase?

Supabase serves your database over REST with PostgREST. PostgREST only answers for the schemas in its db_schema list. You edit it in the Dashboard under Project Settings → Data API → Exposed schemas. On the project config I looked at, the value was public,graphql_public, and the Supabase custom schemas guide says public is exposed by default. A request for a schema outside the list gets refused. PostgREST also never allows pg_catalog or information_schema in it.

Why does the list matter?

Everything inside an exposed schema can be reached with the public key. Tables, views and functions. Supabase's RLS guide is blunt about it. A table in an exposed schema without RLS can be read and written by any role that has a grant on it. A schema that's not on the list can't be reached through the API at all.

So an extra schema is not a neutral change. Add storage or vault to the list and you've put internal tables within reach of the Data API. Add your own schema and every table in it needs the same care as public.

What an extra exposed schema opens. One anon request, before and after trimming the list. Before: an extra schema is exposed: anon request → Data API → extra schema → no-RLS table readable. After: only public and graphql_public: anon request → Data API → default schemas only → RLS on all tables.
From my Supabase security rules and the Supabase Data API docs, 22 October 2026.

Which extra schemas are the worst?

An internal Supabase schema in the list (auth, storage, realtime, vault, extensions and similar) is a high severity problem when I review a project. A custom schema like api is a medium one, a warning. The same list also decides where other problems count: tables with RLS off, anon write grants, always-true policies, views without security_invoker and SECURITY DEFINER functions that anon can call only matter in exposed schemas, because that's where the public key reaches them.

How do you use a custom schema safely?

A custom schema is a legitimate pattern. Supabase's Securing your API guide describes a dedicated schema such as api as a way to make the exposed surface easier to identify and audit, with internal tables kept in schemas that aren't exposed. Two steps make it work, and the custom schemas guide gives both. Add the schema to Exposed schemas, then grant usage on it to anon, authenticated and service_role, plus privileges on its objects.

The guide's example grants ALL on every table. I wouldn't copy that line as it stands. The Securing your API guide says to give each role the minimum it needs, and to enable RLS on every table you expose. Grants decide if a role can touch a table at all, and policies decide which rows.

There's one separate change. Supabase stops granting new public tables to the Data API automatically. It became the default for new projects from 30 May 2026 and will be applied to existing projects on 30 October 2026, and existing tables keep their grants. That is the grants layer. It doesn't change which schemas are on the list.

How do you check your own project?

Open Project Settings → Data API and read Exposed schemas. If it's more than public and graphql_public, ask what each extra schema is for.

Then list the tables in those schemas that have RLS off, and see if anon can already read them:

select n.nspname as schema,
       c.relname as "table",
       has_table_privilege('anon', c.oid, 'select') as anon_can_select
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname in ('public', 'api')  -- your Exposed schemas
  and c.relkind in ('r', 'p')
  and not c.relrowsecurity
order by 1, 2;
Enter fullscreen mode Exit fullscreen mode

I ran it on a throwaway Postgres 17 with an api schema holding one table with RLS and one without, plus a public table without RLS. It returned only the two without:

 schema | table  | anon_can_select
--------+--------+-----------------
 api    | orders | t
 public | logs   | t
Enter fullscreen mode Exit fullscreen mode

Every row with anon_can_select true is a table anyone with your public key can read, until you enable RLS or revoke the grant. The query covers tables only. Views and functions need their own look, and my Lovable app security checklist walks through them. How I test Supabase RLS shows how to prove the policies work.


Originally published at madsalt.tech. Written with AI assistance, checked by me.

Top comments (0)