
Supabase REST API: Quickstart with curl and JavaScript
Every Supabase project comes with a REST API, the Data API, generated from your Postgres schema by PostgREST. It lives at https://<project-ref>.supabase.co/rest/v1/<table>. You call it with your project's API key in the apikey header, and Row Level Security (RLS) decides which rows each caller can read or change. This guide covers where to find the Supabase API URL and keys, how the new publishable and secret keys differ from the legacy anon and service_role keys, and working curl and supabase-js examples for select, insert, update and delete.
What the Supabase Data API is
According to the Supabase API docs, Supabase builds the API from your database structure. Create a table or a view and it becomes a REST endpoint. Schema changes show up in the API right away, and you don't write any server code. Under the hood it is PostgREST, so the URL filter syntax (?id=eq.1, ?order=score.desc) and the error codes are PostgREST's.
The Data API is what the supabase-js client calls when you run supabase.from('table'). You can also call it directly with curl, fetch, or any HTTP client.
Where to find the Supabase API URL and API keys
- Open your project in the Supabase Dashboard.
- Click Connect at the top of the project page. The dialog shows the project URL (
https://<project-ref>.supabase.co) and the publishable key. - For every key, including legacy ones, go to Settings > API Keys.
- The Data API settings (exposed schemas, the API URL) are under Integrations > Data API.
You can also list keys with the CLI: supabase projects api-keys --project-ref <ref>. A local stack (supabase start) prints its own local URL and keys, which are separate from the hosted ones.
Publishable and secret keys vs legacy anon and service_role
Supabase now issues two kinds of keys (API keys docs):
| Key | Format | Where it goes | What it can do |
|---|---|---|---|
| Publishable | sb_publishable_... |
Browser, mobile app, CLI | Low privilege. RLS limits it. |
| Secret | sb_secret_... |
Servers, Edge Functions, cron jobs | Bypasses RLS. Never ship it to a client. |
Legacy anon |
JWT starting eyJ |
Same as publishable | Same role as publishable |
Legacy service_role |
JWT starting eyJ |
Same as secret | Same role as secret |
Things to know:
- Supabase plans to deprecate the legacy
anonandservice_rolekeys by the end of 2026. Creating new keys does not turn the old ones off. You deactivate them yourself in Settings > API Keys once nothing uses them. - If a browser sends a secret key, Supabase detects it from the
User-Agentand returns401. That does not protect a leaked key, since anyone can send it with curl. Delete a leaked secret key immediately. - Send publishable and secret keys in the
apikeyheader. They are not JWTs, so don't put them inAuthorization: Bearer. With the legacy keys, many examples sent the same key in both headers. That pattern doesn't carry over.
Supabase PAT (personal access token) is a different thing. A PAT, created under your account's Access Tokens page, authenticates you to the Management API (api.supabase.com), the CLI and tools that manage projects. It is not used for the Data API and should never be put in an app.
Before your first request: grants and RLS
Two layers decide whether a request works (Securing your API):
- Grants decide whether a role (
anon,authenticated,service_role) can touch the table at all. - RLS policies decide which rows that role sees or changes.
Supabase is changing the default grants (announcement). New projects created since May 30, 2026 no longer expose new public tables automatically, and from October 30, 2026 the same applies to new tables in existing projects. Existing tables keep their grants. If your new table returns a permission error, grant access explicitly:
create table public.leaderboard (
id bigint generated always as identity primary key,
player text not null,
score int not null default 0,
created_at timestamptz not null default now()
);
alter table public.leaderboard enable row level security;
grant select on public.leaderboard to anon, authenticated;
grant insert, update, delete on public.leaderboard to authenticated;
create policy "anyone can read" on public.leaderboard
for select to anon, authenticated using (true);
create policy "signed-in users can write" on public.leaderboard
for insert to authenticated with check (true);
Tables created in the Table Editor have RLS on by default. Tables created in SQL do not, so always add the enable row level security line. A table with RLS on and no policy returns no rows to anon and authenticated.
curl examples
Set two shell variables first:
export SB_URL="https://<project-ref>.supabase.co"
export SB_KEY="sb_publishable_..."
Select (with columns, a filter, ordering and a limit):
curl "$SB_URL/rest/v1/leaderboard?select=player,score&score=gte.100&order=score.desc&limit=10" \
-H "apikey: $SB_KEY"
Common filter operators: eq, neq, gt, gte, lt, lte, like, ilike, in.(a,b), is.null. They all follow the column=operator.value pattern.
Insert (Prefer: return=representation returns the new row):
curl -X POST "$SB_URL/rest/v1/leaderboard" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"player": "ada", "score": 120}'
Update (always filter, or you target every row you are allowed to change):
curl -X PATCH "$SB_URL/rest/v1/leaderboard?id=eq.1" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"score": 150}'
Delete:
curl -X DELETE "$SB_URL/rest/v1/leaderboard?id=eq.1" \
-H "apikey: $SB_KEY" \
-H "Authorization: Bearer $USER_ACCESS_TOKEN"
Upsert: send a POST with Prefer: resolution=merge-duplicates. A row whose primary key already exists is updated instead of rejected.
Auth headers in short
apikeyis required on every request. It identifies the project and sets the base role.Authorization: Bearer <access_token>is optional. It carries a signed-in user's JWT from Supabase Auth, so RLS policies run asauthenticatedandauth.uid()returns that user. Without it, the request runs asanon.- On a server, sending the secret key in
apikey(and no user token) runs asservice_roleand skips RLS.
The same thing with supabase-js
Install the client with npm install @supabase/supabase-js, then:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://<project-ref>.supabase.co',
'sb_publishable_...'
);
// select
const { data: top, error } = await supabase
.from('leaderboard')
.select('player, score')
.gte('score', 100)
.order('score', { ascending: false })
.limit(10);
// insert and return the new row
const { data: row } = await supabase
.from('leaderboard')
.insert({ player: 'ada', score: 120 })
.select()
.single();
// update
await supabase.from('leaderboard').update({ score: 150 }).eq('id', 1);
// delete
await supabase.from('leaderboard').delete().eq('id', 1);
The client adds the apikey header for you. Once a user signs in with supabase.auth, it also sends their access token, so RLS sees them as authenticated. Python works the same way with supabase-py (from supabase import create_client).
Common Supabase API errors
| Status / code | Usual cause | Fix |
|---|---|---|
401 "No API key found in request" |
Missing apikey header |
Add the header (or ?apikey= for a quick browser test) |
401 "Invalid API key" |
Wrong project, typo, or a deactivated legacy key | Copy the key again from Settings > API Keys |
401 with a secret key from a browser |
Secret keys are blocked in browsers | Use the publishable key on the client |
401/403 code 42501 "permission denied for table" |
Role has no grant on the table | grant select ... to anon, authenticated |
42501 "new row violates row-level security policy" |
No insert/update policy matches | Add a policy, or send the user's access token |
200 with [] |
RLS is on and no select policy matches | Add a select policy for that role |
404 PGRST205 |
Table not found (wrong name, or schema not exposed) | Check the name and Integrations > Data API exposed schemas |
400 PGRST100 |
Bad query string syntax | Check column=operator.value formatting |
406 PGRST116 |
.single() got zero or several rows |
Use .maybeSingle() or tighten the filter |
409 code 23505 |
Unique constraint violation | Upsert, or check for the row first |
The full list is in the PostgREST error reference. For a general walkthrough of 4xx debugging, see our guide to 400 Bad Request in REST APIs.
Limitations worth knowing
- The Data API exposes tables, views and functions, not arbitrary SQL. For complex logic, write a Postgres function and call it at
/rest/v1/rpc/<function>. - Anything the publishable key can reach is public to anyone who opens your site. Your RLS policies are the only security boundary.
- Large result sets are capped by the project's max rows setting. Page through them with
limitandoffsetor aRangeheader.
If you are still choosing a backend, compare it on the Supabase listing on DevHunt or browse free Firebase alternatives. For other APIs you can try without a credit card, see free APIs for developers.
FAQ
What is the Supabase API URL?
It is https://<project-ref>.supabase.co, and the REST endpoint for a table is https://<project-ref>.supabase.co/rest/v1/<table>. You'll find it in the Connect dialog of your project or under Integrations > Data API.
How do I get my Supabase URL and anon key?
Click Connect in your project dashboard to see the URL and the publishable key, which replaces the anon key. The legacy anon key is still listed under Settings > API Keys until you deactivate it, but new code should use the sb_publishable_ key.
Is the Supabase publishable key safe to expose?
Yes, it is meant to be public. It only works as the anon or authenticated role, so what it can do depends entirely on your grants and RLS policies. The secret key and the service_role key must stay on the server.
Is the Supabase API the same as PostgREST?
The Data API is PostgREST running behind Supabase's API gateway. The gateway adds key checks and connects PostgREST to Supabase Auth. The query syntax, headers like Prefer, and error codes are standard PostgREST.
What is a Supabase PAT used for?
A personal access token authenticates you, the account owner, to the Management API and the CLI, for example to create projects or run migrations. It cannot read table data through the Data API, and it should never go into application code.