Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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 callURL 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:

TableRLSPolicies
profilesenabledown profile — select
ordersenabledown orders — select
entitlement_slotsenabledown slots — select
download_eventsenabledown downloads — select
download_deliveriesenabledown deliveries — select
refund_requestsenabledown 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

KeyWhere it livesWhat RLS does to it
anonPublic — in your JS bundleApplies fully; unauthenticated role
User access tokenBrowser, with the anon key as apikeyApplies as the authenticated role; auth.uid() resolves
service_roleServer only, never shippedBypassed 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

SymptomCauseFix
401 with No API key found in requestMissing apikey headerSend the anon key as apikey, the user JWT as Authorization
Empty array instead of a 403RLS filters rows; it does not errorExpected. Check auth.uid() matches the row's owner column
Rows readable that should not beRLS enabled but no policy restricting select, or no RLS at allEnable RLS per table; a policy on one table protects only that table
permission denied for schema publicRole lacks schema usage after a manual grant changeSee the dedicated write-up linked below
404 on an rpc call that existsSignature changed, or the schema cache is staleMatch argument names exactly; reload the schema cache
42501 on an rpc callEXECUTE revoked from your role — as aboveCall it from server code with the service-role client
Query times out on a large tableStatement timeout, not the APIIndex the filtered column; see the timeout post
Writes succeed from curl with the anon keyA permissive for all policyScope 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.

Keep reading

Tutorial8 min read

Supabase SQL Editor: This Schema Has Never Been Edited Through It

Studio's SQL Editor is built for one-off queries. Every schema change here shipped instead as one of 8 reviewed migration files — what each tool is actually for.

Read more
Tutorial8 min read

Supabase UI vs Hand-Rolled Auth: What 812 Lines Actually Buy

This storefront's auth stack is 812 lines of Server Actions, not the Supabase UI library. What the library would replace, and what it wouldn't touch at all.

Read more