The Supabase REST API: What PostgREST Exposes, and What Stops It
Every public table and function becomes an endpoint the moment it exists. Six tables, six select-only policies, four RPCs with EXECUTE revoked.
Supabase's REST API is PostgREST. Every table, view and function in an exposed schema becomes an HTTP endpoint under /rest/v1 the moment it exists — no code, no deploy, no opt-in. The anon key that reaches those endpoints ships in your browser bundle, so row-level security and function grants are the only things deciding what a stranger gets back.
You are already using it
The supabase-js client is not an alternative to the REST API; it is a client for it. From the installed @supabase/supabase-js@2.110.0 bundle:
this.rest = new PostgrestClient(new URL("rest/v1", baseUrl).href, { ... });
So every .from() chain you write compiles to a URL. This is a real query from src/lib/actions/entitlementsView.ts, which resolves what a signed-in buyer owns:
const { data, error } = await supabase
.from("entitlement_slots")
.select("kind, product_slug, framework, status")
.eq("user_id", user.id)
.eq("status", "active");
And this is the request it makes:
GET /rest/v1/entitlement_slots?select=kind,product_slug,framework,status
&user_id=eq.<uuid>&status=eq.active
apikey: <anon key>
Authorization: Bearer <user access token>
| Builder call | URL fragment |
|---|---|
.from("entitlement_slots") | the path segment |
.select("a, b") | ?select=a,b |
.eq("status", "active") | &status=eq.active |
.order("created_at") | &order=created_at |
.limit(10) | &limit=10 |
.single() | Accept: application/vnd.pgrst.object+json |
.rpc("fn", args) | POST /rest/v1/rpc/fn |
Reading the table once is worth it, because it explains a class of confusion: anything you can do from the client, anyone can do with curl and the key from your bundle. Nothing in the JavaScript is a gate.
Calling it without a client library
Three things make a request valid: the path, the apikey header, and — if the rows are user-scoped — an Authorization bearer token. Nothing else is required, which is why a webhook consumer or a shell script does not need a client library at all:
curl "$SUPABASE_URL/rest/v1/orders?select=id,status&order=created_at.desc&limit=5" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
Two header-level features are worth knowing before you reach for a second round trip. Prefer: return=representation makes a write return the rows it wrote, instead of an empty body you then have to go and fetch. And Prefer: count=exact puts the matching row count in the Content-Range response header, which is how a paginated table gets its total without a second query.
Embedding is the other thing people write by hand in three requests. PostgREST follows foreign keys inside select, so a related table comes back nested:
GET /rest/v1/orders?select=id,status,entitlement_slots(product_slug,framework)
RLS still applies to the embedded table on its own terms — the join does not borrow the parent's permission. That is the behaviour you want, and it is also the reason an embed can silently return an empty array while the parent row looks fine.
What this project exposes, audited
Eight migrations under supabase/migrations/ define the commerce schema. The REST surface they create, counted rather than remembered:
| Table | RLS | Policies |
|---|---|---|
profiles | enabled | own profile — select |
orders | enabled | own orders — select |
entitlement_slots | enabled | own slots — select |
download_events | enabled | own downloads — select |
download_deliveries | enabled | own deliveries — select |
refund_requests | enabled | own refund requests — select |
Six tables, six policies, and the number of for insert, for update or for delete policies in the whole repository is zero. Every policy is the same shape:
alter table public.orders enable row level security;
create policy "own orders" on public.orders
for select using ((select auth.uid()) = user_id);
The consequence is the point. Over REST, with a valid user token, a buyer can read their own rows and nothing else — and can write nothing at all. No POST /rest/v1/orders, no PATCH, no DELETE. Writes happen in server code holding the service-role key, or inside a transactional function. A client that cannot write is a client that cannot grant itself an entitlement.
The (select auth.uid()) wrapper is not stylistic. Written bare, auth.uid() is re-evaluated per row; wrapped in a scalar subquery, Postgres evaluates it once and caches it, which is the difference between a policy that scales with the table and one that does not.
/rest/v1/rpc exposes your functions too
This is the half that surprises people. PostgREST publishes functions in the exposed schema as POST /rest/v1/rpc/<name>. Create a helper with SECURITY DEFINER to do a privileged write, and you have published a privileged write to the internet unless you say otherwise.
Four functions exist here — create_order_with_slots, refund_order, downloads_in_last_hour, record_download_within_limit — and all four have EXECUTE revoked from the client roles:
revoke execute on function public.create_order_with_slots(...) from anon, authenticated, public;
revoke execute on function public.refund_order(text) from anon, authenticated, public;
revoke execute on function public.downloads_in_last_hour(uuid) from anon, authenticated, public;
Migration 0004_lock_down_rate_limit_fn.sql exists for nothing else. Its entire body is one revoke, and its comment says why it shipped anyway:
-- Consistency/defense-in-depth: the download endpoint calls downloads_in_last_hour
-- via the service-role admin client, so revoking EXECUTE from public roles doesn't
-- affect it (it's security-invoker + RLS so it was already non-leaking).
That is the habit worth copying: the function was not leaking, and the revoke shipped regardless, so that "which functions can an anonymous caller invoke" has one answer — none — instead of an answer per function. The same migrations pin set search_path = public, pg_temp on every definer function, which stops a caller from resolving a name inside it to something of their own.
The keys, over REST
| Key | Where it lives | What RLS does to it |
|---|---|---|
anon | Public — in your JS bundle | Applies fully; unauthenticated role |
| User access token | Browser, with the anon key as apikey | Applies as the authenticated role; auth.uid() resolves |
service_role | Server only, never shipped | Bypassed entirely |
A service-role key in a client bundle turns the REST API into an unauthenticated admin console over your whole database. How this project keeps it server-side — a separate admin client, never imported from a Client Component — is in the service role key, and the three client factories are walked through in Supabase with JavaScript.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 with No API key found in request | Missing apikey header | Send the anon key as apikey, the user JWT as Authorization |
Empty array instead of a 403 | RLS filters rows; it does not error | Expected. Check auth.uid() matches the row's owner column |
| Rows readable that should not be | RLS enabled but no policy restricting select, or no RLS at all | Enable RLS per table; a policy on one table protects only that table |
permission denied for schema public | Role lacks schema usage after a manual grant change | See the dedicated write-up linked below |
404 on an rpc call that exists | Signature changed, or the schema cache is stale | Match argument names exactly; reload the schema cache |
42501 on an rpc call | EXECUTE revoked from your role — as above | Call it from server code with the service-role client |
| Query times out on a large table | Statement timeout, not the API | Index the filtered column; see the timeout post |
Writes succeed from curl with the anon key | A permissive for all policy | Scope policies per command; keep writes in server code |
Two of those have their own posts, because the error messages mislead: permission denied for schema public and canceling statement due to statement timeout.
Frequently asked questions
Do I have to enable the REST API?
No — the opposite. It is on for the exposed schemas by default, so the decision you own is which schemas are exposed and what RLS says. Moving private tables out of public into a schema you do not expose is a legitimate design, not a workaround.
Is calling /rest/v1 directly better than supabase-js?
It is the same traffic. Use curl or fetch where a client library is overkill — a webhook consumer, a shell script, a serverless function in another language. The builder's value is types and composition, not a different protocol.
Does the anon key being public mean my data is public? Only for tables where RLS is off or a policy allows it. The key identifies the project and the role; policies decide the rows. Treat the key as public and the policies as the security boundary, because that is what they are.
Why use an RPC instead of several REST calls? Atomicity. Recording a download against an hourly cap, or creating an order plus its entitlement slots, must either happen completely or not at all. Three sequential REST writes from a client cannot promise that; one function in one transaction can.
Templates in this post
ASoc Coin is a neobank landing page built around a cash-overview dashboard mock and account-linking features, ASoc Compound is a robo-investing site with a portfolio-value preview and goal-based messaging, and ASoc Cortex markets an AI solutions agency through an impact console, case studies and an FAQ funnel. All three are statically prerendered, so the only thing talking to your database is the code you add.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
