Securing the Data API
The idea in one sentence
Section titled “The idea in one sentence”A table or function must pass two separate gates before a request is both safe and successful: Data API exposure decides if it is reachable through the API at all, and Row Level Security then decides which rows a reachable request can see or change.
Layer 1 — Data API exposure
Section titled “Layer 1 — Data API exposure”Many older Supabase tutorials describe RLS as the whole story. It is not. Before RLS is even evaluated, Postgres must have granted the requesting role — anon, authenticated, or service_role — permission to touch the table or function in the first place.
The local CLI stack you have been using since the Foundations module does not follow this rollout. Proof, run today (2026-09-29) against this course’s own local stack, with zero configuration beyond a plain supabase start:
-- supabase/migrations/4001_agapi_demo_table.sqlcreate table public.agapi_demo ( id uuid primary key default gen_random_uuid(), val text);
insert into public.agapi_demo (val) values ('hello-from-postgres');No grant, no dashboard, no “Default privileges” toggle — query it as anon immediately:
curl "$API_URL/rest/v1/agapi_demo?select=*" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"HTTP/1.1 200 OK[{"id":"c00eb34c-...","val":"hello-from-postgres"}]\dp public.agapi_demo confirms why — anon and authenticated already hold full privileges the instant the table exists:
Access privileges Schema | Name | Type | Access privileges | Column privileges | Policies--------+------------+-------+---------------------------------+-------------------+---------- public | agapi_demo | table | postgres=arwdDxtm/postgres +| | | | | anon=arwdDxtm/postgres +| | | | | authenticated=arwdDxtm/postgres+| | | | | service_role=arwdDxtm/postgres | |(1 row)The reason is in the CLI’s own scaffolded supabase/config.toml, commented out but explained in full:
# Controls whether new tables, views, sequences and functions created in the `public` schema by# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)# without explicit GRANTs, matching the cloud default. Set to `false` to require explicit GRANTs# instead. Left unset, a fresh project falls back to `true`.# auto_expose_new_tables = trueUncommenting that line as auto_expose_new_tables = false in your own project’s supabase/config.toml makes local development match a new hosted project post-May-30-2026: new tables stop being auto-granted, and you must grant explicitly, same as the SQL below. (This lesson does not flip that setting on the course’s own shared local stack — other lessons and other readers rely on the current default — but it is the one line you add to a real project once you want local behavior to match the new hosted default.)
Under the hood, whichever way exposure happens — auto-grant, dashboard, or hand-written SQL — it is always ordinary Postgres grant statements:
-- Table grants: what each role is allowed to attemptgrant select on public.your_table to anon;grant select, insert, update, delete on public.your_table to authenticated;grant all on public.your_table to service_role;
-- Function grants: who may call this RPC endpointgrant execute on function public.your_function to authenticated, service_role;If a role has no grant on a table, that table is invisible to it through the Data API — the request fails before RLS is ever checked, regardless of how permissive your RLS policies are. This is a table/function-level gate: it answers “can this role touch this object at all,” not “which rows.” Proved by revoking anon’s grant on the table above and repeating the exact same request:
-- supabase/migrations/4002_agapi_revoke_anon.sqlrevoke select on public.agapi_demo from anon;HTTP/1.1 401 Unauthorized{"code":"42501","details":null,"hint":"Grant the required privileges to the current role with: GRANT SELECT ON public.agapi_demo TO anon;","message":"permission denied for table agapi_demo"}42501 is Postgres’s own insufficient_privilege SQLSTATE — PostgREST passes it straight through, and even names the exact grant statement that fixes it in hint. Nothing about RLS ran; there is no policy on this table at all. service_role still reads the same row after the revoke (it was never touched), because service_role’s own grant is separate.
Exposed schemas — [api] schemas and what “not exposed” really means
Section titled “Exposed schemas — [api] schemas and what “not exposed” really means”Every project’s supabase/config.toml also names which schemas PostgREST looks in at all, independent of per-table grants:
[api]schemas = ["public", "graphql_public"]A table can have every grant right and still be completely unreachable if its schema is not in that list — proved by creating a fully granted table in a schema that was never added to schemas:
-- supabase/migrations/4003_agapi_priv_schema.sqlcreate schema agapi_priv;
create table agapi_priv.secret_stuff ( id int primary key, val text);
grant usage on schema agapi_priv to anon, authenticated;grant select on agapi_priv.secret_stuff to anon;curl "$API_URL/rest/v1/secret_stuff?select=*" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"HTTP/1.1 404 Not Found{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.secret_stuff' in the schema cache"}usage on the schema, select on the table — both grants are correct — and PostgREST still returns 404 PGRST205, not 401/403. It does not say “forbidden”; it says the table does not exist, because PostgREST only introspects schemas listed in [api] schemas, and agapi_priv was never one of them (note the error even names public.secret_stuff, defaulting to the public schema it does search, since no Accept-Profile header named agapi_priv either). This is the error you get for two completely different mistakes — a genuinely missing table, and a real table Postgres would happily let you touch outside the Data API — which is exactly the trap in the Reading AI-generated code section below.
Layer 2 — Row Level Security
Section titled “Layer 2 — Row Level Security”Once a table clears the exposure gate, Row Level Security (RLS) takes over and answers a narrower question: for a request arriving as anon, authenticated, or a specific authenticated user, which individual rows can it see or modify? RLS policies are the subject of the entire next module, so the detail to hold onto here is just the boundary between the two layers:
- Data API exposure — table/function-level. Is this object reachable through the API at all?
- Row Level Security — row-level. Given that it is reachable, which rows does this specific request get?
Both must be configured correctly. Exposure without RLS means a technically reachable table returns every row to anyone who is granted access. RLS without exposure means correctly scoped policies that no request can ever reach, because the table was never exposed in the first place.
-- Layer 2 lives on the table once it clears layer 1alter table public.your_table enable row level security;
create policy "users read their own rows"on public.your_tablefor selectto authenticatedusing (auth.uid() = user_id);service_role bypasses RLS entirely
Section titled “service_role bypasses RLS entirely”Postgres row level security is skipped for any role with the BYPASSRLS attribute, and service_role has it. A request authenticated with the service_role key still has to clear layer 1 (it needs the underlying grants, though service_role is typically granted broad access), but once it does, RLS policies do not apply to it at all — it sees and can modify every row. This is exactly why the service_role key must never ship to a browser or mobile client, echoing the warning from the Foundations module: it is meant for trusted server-side code only, where you enforce access rules yourself instead of relying on RLS.
flowchart TB
req["Incoming request as anon, authenticated, or service_role"] --> gate1{"Layer 1: Data API exposure — is the table/function reachable at all?"}
gate1 -- "no grant" --> reject1["Rejected: not reachable"]
gate1 -- "granted" --> role{"Role has BYPASSRLS?"}
role -- "yes (service_role)" --> allrows["All rows visible, RLS skipped entirely"]
role -- "no" --> gate2{"Layer 2: Row Level Security — which rows?"}
gate2 -- "no matching policy" --> reject2["Rejected: no rows returned/modified"]
gate2 -- "policy matches" --> scoped["Only matching rows visible/modifiable"] Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant asked to “lock down a private settings table so only the API can’t touch it directly” produced this migration:
🐛 Spot the bug
create schema app_private;
create table app_private.feature_flags (key text primary key,enabled boolean not null default false);
grant usage on schema app_private to service_role;grant select on app_private.feature_flags to service_role;
-- "service_role only, so it's safe to read from an Edge Function"Show the answer
The migration is internally consistent and the grants are genuinely correct — service_role really can read this table, and anon/authenticated really cannot, because they were never granted anything. The bug is what the author believes this achieves: they wrote a comment claiming the table is now reachable “from an Edge Function” via service_role over the Data API, i.e. via supabase.from('feature_flags') with a service-role client. It never will be, for a reason that has nothing to do with the grants: app_private is not in [api] schemas (which defaults to ["public", "graphql_public"]), so PostgREST never introspects it at all. Any request — anon, authenticated, or service_role — gets the same 404 PGRST205 "Could not find the table", proved above for exactly this shape of mistake. The fix is not a grant; it is realizing that a Data API client was never the right tool here in the first place — either add app_private to schemas (which somewhat defeats the “private” in the name, since every Data API role can now query it, gated only by grants) and send Accept-Profile: app_private, or — the actual fix for “server-only settings” — read it with a direct Postgres connection (e.g. from an Edge Function using postgres.js/pg against the pooler) and never route it through PostgREST at all.
Mental Model
Section titled “Mental Model”Think of every request as walking through a building with two independent doors, not one: the front door (Data API exposure — a Postgres grant, on a schema PostgREST was told to look in) and an interior door per room (RLS, evaluated per row once you’re already inside). A locksmith who only fits the interior doors with locks but leaves the front door unlocked has secured nothing an attacker cares about; conversely, bricking up the front door to a room that never had an interior lock just means legitimate traffic can’t get in either. \dp <table> answers “is the front door unlocked for this role”; [api] schemas answers “does this building even appear on the map PostgREST is holding”; RLS policies are the only thing that ever looks inside once both of those say yes.
Trade-offs
Section titled “Trade-offs”- Auto-expose (current local default, previous hosted default) is fast to prototype with — every new table just works from the client immediately — at the cost of a silent security default: a table with sensitive columns is reachable the moment
create tableruns, before you have written a single RLS policy, unless you remember to lock it down. auto_expose_new_tables = false/ the new hosted default is safer by construction — nothing is reachable until you say so — at the cost of a step every table needs: forget thegrant, and a legitimate feature silently 404s in a way that is easy to mistake for “the table doesn’t exist” rather than “the table exists but isn’t exposed.”- Keeping a table out of
[api] schemasentirely is the strongest form of “not reachable through PostgREST,” stronger than revoking grants (no role, however privileged, gets in through REST/GraphQL) — but it also means you cannot expose it later with just agrant; you have to add the schema, which affects every table in it, not just one.
Common Misconceptions
Section titled “Common Misconceptions”- “RLS is the whole security model.” It is the second of two gates. A table with
enable row level securityand zero grants is not “extra safe” — it is simply unreachable, and a table with broad grants and no policies is reachable by anyone with a matching role, in full. - “A 404 from PostgREST means the table doesn’t exist.” It means PostgREST’s schema cache doesn’t have an entry matching that name in a schema it was told to search — which is also what you get for a table that exists, is fully granted, and sits one schema away from where you think it is.
- “Since my hosted project shows ‘not exposed by default’ in the docs, my local dev stack behaves the same way.” Proved false above: as of this writing the CLI’s own scaffolded
auto_expose_new_tablesstill falls back totrue. Docs describing hosted defaults are not automatically true ofsupabase start. - “Fixing a 404/403 always means changing a grant.” Sometimes it means adding a schema to
[api] schemas, or sending anAccept-Profile/Content-Profileheader for a non-publicschema — no amount ofgrantfixes an unlisted schema.