Skip to content

The Supabase Architecture

Every request to Supabase’s auto-generated API arrives at Postgres carrying a Postgres role — anon, authenticated, or service_role — and that role is the architectural backbone that everything else (the Data API, Row Level Security) is built on top of.

Supabase’s auto-generated API authenticates every request, or treats it as explicitly anonymous. Whichever it is, the request reaches Postgres as one of three roles:

  • anon — an unauthenticated request. No user is logged in.
  • authenticated — a request from a logged-in user, carrying their JWT claims, including auth.uid() for identifying which user it is.
  • service_role — trusted server-side code. This role bypasses Row Level Security entirely, the same key you already met in the previous lesson.
-- Inside a Row Level Security policy, this is exactly how you tell roles apart.
create policy "Users can view their own profile"
on public.profiles
for select
to authenticated
using (auth.uid() = id);

Proved with a real RPC function, callable through the Data API, that simply reports back which role executed it:

-- sql/fnd_whoami.sql (already applied — reference only, not re-collected by the harness)
create or replace function public.fnd_whoami()
returns table(who text, pg_role text)
language sql
security invoker
as $$
select current_user::text, current_setting('role', true)::text;
$$;
grant execute on function public.fnd_whoami() to anon, authenticated;
$ curl -s -X POST "$API_URL/rest/v1/rpc/fnd_whoami" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Content-Type: application/json" -d '{}'
[{"who":"anon","pg_role":"anon"}]
$ curl -s -X POST "$API_URL/rest/v1/rpc/fnd_whoami" -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" -H "Content-Type: application/json" -d '{}'
[{"who":"service_role","pg_role":"service_role"}]

Same function, same connection pool underneath, and current_user itself changes depending on which key signed the request — not a header PostgREST merely reads and trusts, but the actual Postgres session role the query executes as.

PostgREST translates HTTP into SQL — it does not enforce anything itself

Section titled “PostgREST translates HTTP into SQL — it does not enforce anything itself”

PostgREST is the service that turns an HTTP request like GET /rest/v1/profiles?select=* into an actual SQL query, and executes that query as the role the request carries. This detail matters more than it looks: PostgREST does not implement its own separate permission system. It relies entirely on Postgres’s own access control — plain grant/revoke permissions, and Row Level Security policies — to decide what that role is actually allowed to read or write. Access control lives in the database, not in the API layer sitting in front of it.

// This client call becomes an HTTP request to PostgREST, executed as
// whatever role the current session carries (anon or authenticated).
const { data, error } = await supabase
.from('profiles')
.select('id, username')
.eq('id', userId);

The other services also just read and write Postgres

Section titled “The other services also just read and write Postgres”

Auth, Storage, and Realtime are separate services (separate processes), but none of them invent their own storage layer — they all ultimately read and write the same Postgres database:

  • Auth (GoTrue) manages the auth schema — auth.users, sessions, and related tables.
  • Storage manages its own metadata tables (buckets, objects, permissions) in the storage schema; the files themselves live in an object store, but access rules are Postgres rows.
  • Realtime reads Postgres’s write-ahead log (WAL) to stream row changes, in addition to offering ephemeral broadcast/presence channels.
flowchart LR
  client["Client request (anon / authenticated / service_role)"] --> postgrest["PostgREST"]
  postgrest -->|"executes SQL as that role"| pg[("Postgres<br/>(grants + RLS enforced here)")]
  auth["Auth (GoTrue)"] --> pg
  storage["Storage"] --> pg
  realtime["Realtime (reads WAL)"] --> pg
A request's role travels through PostgREST into Postgres; other services share the same database

An AI assistant, asked to “add a check that only admins can call this function,” produced this:

🐛 Spot the bug

-- An AI assistant's attempt at an "admin-only" RPC guard
create or replace function public.fnd_admin_report()
returns table(total bigint)
language plpgsql
security definer
as $$
begin
if current_user != 'service_role' then
raise exception 'admins only';
end if;
return query select count(*) from public.fnd_widgets;
end;
$$;
Show the answer

This looks like a real guard, and it would even work if it were only ever called with the service_role key directly against Postgres — but it was written to be exposed through the Data API as an RPC function, where every request that reaches it has already gone through PostgREST as some role. The bug: security definer makes this function run with the function owner’s privileges (typically postgres), which the RPC-request proof above already established current_user reflects for the caller’s request role, not the owner — so this check is comparing the wrong thing entirely for most call shapes, and worse, a security definer function’s real danger (this lesson’s neighboring RPC lesson covers it in depth) is that it needs set search_path = '' to avoid schema-injection, which is missing here too. The fix that actually matches this course’s own architecture: do not try to check current_user inside a security definer function at all — either drop security definer (default to security invoker, so RLS and grants on fnd_widgets decide access the normal way) or, if elevated privileges are genuinely required, check the caller’s real identity via auth.uid() against a profiles.is_admin flag (the pattern the Edge Functions module proves), never by comparing current_user to a role name.

Think of every request to the Data API as arriving at Postgres already wearing a name tag — the Postgres role — and everything downstream (grants, RLS policies) reads that name tag, not the request’s headers or body. PostgREST’s only job is choosing which name tag to attach (anon, authenticated, or service_role, based on the JWT it was handed) and then getting out of the way; the proof above shows the name tag is not a polite fiction PostgREST tracks internally, it is the actual current_user Postgres itself sees for the duration of that request.

  • Enforcing access control entirely inside Postgres (grants + RLS) means every client — REST, GraphQL, a future client this course has not covered yet — gets the same guarantees automatically — at the cost of needing to think in terms of roles and policies instead of the more familiar “if (user.isAdmin)” check, proved above to fail exactly that way when ported naively into a security definer function.
  • security definer lets a function do something the caller’s own role could not do directly (the admin-report pattern, done correctly) — at the cost of the function itself becoming the entire access-control boundary, since Postgres no longer applies the caller’s own restrictions inside it at all.
  • PostgREST translating HTTP straight into SQL keeps the API surface automatically in sync with the schema — at the cost of the schema’s own grants and RLS being the only thing standing between “reachable” and “secure,” with no separate application-layer authorization code to fall back on if the database-level rules are wrong.
  • “Only anon and authenticated actually matter — service_role is just a convenience for scripts.” The whoami proof above treats it identically: it is a real Postgres role like the other two, just one with bypassrls — nothing about it is special-cased outside that one attribute.
  • “A security definer function automatically knows who the original caller was.” Disproved by the SpotTheBug above: current_user inside a security definer function reflects the function’s owner, not the caller, which is exactly why checking current_user for authorization inside one is backwards.
  • “PostgREST has its own user/session system separate from Postgres.” There is no separate session store — proved above, PostgREST’s entire authorization mechanism is picking which literal Postgres role to run the query as.
Which Postgres role does an unauthenticated request use?
Where is access control actually enforced for Supabase's auto-generated API?
What role does trusted server-side code use to bypass Row Level Security?
How do Auth, Storage, and Realtime relate to the underlying Postgres database?
An AI-generated RPC function checks if current_user != 'service_role' then raise exception to enforce "admins only," but the function is declared security definer. Why does this check not do what it looks like it does?