Notes
How to tell if your Supabase tables are readable with the anon key
8 Oct 2026 · 1522 words
The publishable key in your Next.js app is not a secret. Check grants and RLS, then prove it with curl against the REST API.
The Supabase key you put in the browser is meant to be public. Older projects ship a long JWT labeled anon. Newer projects ship a sb_publishable_… key. Either way, anyone who can open DevTools or download your JS can copy it. That is not a breach by itself.
The breach is what that key can do. Postgres decides that with two checks, in order: table grants, then Row Level Security. If a table sits in an exposed schema, has GRANTs for the anon role, and has RLS off, the Data API will hand those rows to anyone who presents the publishable key.
This post is the shortest path I use on a Vercel + Supabase app: confirm which key is in the client, ask the REST API what anon can see, then lock the table with grants and policies you can re-test.
What the publishable key actually is
Every request to your project carries an API key. The key answers "what is calling" — a web page, a mobile app, a server. Supabase Auth, when someone is signed in, answers "who is calling."
Supabase maps those two facts onto Postgres roles:
- Publishable / legacy
anonkey, nobody signed in →anon - Publishable key plus a user JWT →
authenticated - Secret / legacy
service_rolekey →service_role
service_role has BYPASSRLS. It is a master key. It belongs in a Route Handler, a cron, or an Edge Function — never in a Client Component and never in a NEXT_PUBLIC_* variable. If that key is in your bundle, stop reading this and rotate it. The client-bundle post covers that path.
The publishable key is the front-door key. It is supposed to open only what you wrote policies for.
Grants and RLS are not the same thing
Postgres runs grants first. A missing grant returns 42501 and the policy never runs. A grant with no matching policy, once RLS is on, returns an empty result. Those two failures look different in curl, which is useful.
On many existing projects, a new table in public already has SELECT, INSERT, UPDATE, and DELETE for anon, authenticated, and service_role. Adding a policy does not revoke those grants. A table that is "protected only by policies" can still give anon an insert path if you never took the grant back.
RLS off is worse. Supabase's own warning is blunt: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Enable RLS on every table you expose through the Data API. Tables created in the Dashboard usually have RLS on. Tables created in the SQL Editor or by a migration often do not.
Views need the same attention. A view created as the postgres user is security definer by default and can bypass the RLS on the tables underneath it.
Prove it with the REST API
You do not need the Dashboard to see what anon can read. PostgREST is the Data API at /rest/v1/. Use the same key your browser already has.
For a legacy anon JWT, send it on both headers — that is what hosted projects have done for years:
bash
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" ANON_KEY="eyJ…your-anon-jwt" curl -sS -D - \ "$SUPABASE_URL/rest/v1/profiles?select=*&limit=5" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY" \ -H "Accept: application/json"
For a sb_publishable_… key, official guidance is to put it on apikey, not on Authorization: Bearer. Those keys are not JWTs. A user session, if you have one, still goes on Authorization as the user JWT.
bash
PUBLISHABLE_KEY="sb_publishable_…" curl -sS -D - \ "$SUPABASE_URL/rest/v1/profiles?select=id,display_name&limit=5" \ -H "apikey: $PUBLISHABLE_KEY" \ -H "Accept: application/json" \ -H "Prefer: count=exact"
Read the status and the body separately.
200with a JSON array of rows:anoncanSELECTthose rows. If you did not intend a public directory, that is the bug.200with[]: the grant exists and RLS is on, but no row matched. An empty table and a locked-down table look the same here. Do not treat empty as safe.401/403or a JSON error with code42501: the grant or the policy blocked the call. That is the result you want for private tables.PGRST205/ schema-cache errors: the table name is wrong or not exposed. Check the name before you change policies.
Prefer: count=exact adds a Content-Range header when PostgREST can count. A range like 0-4/128 means there are 128 visible rows even if you only asked for five. You do not need to download the table to learn it is public.
Repeat the GET for every table you care about: profiles, users, orders, invoices, messages, anything an AI-assisted scaffold created for you. Scaffolds often leave profiles world-readable because the first tutorial used using (true).
Ask the catalog, not just one table
The REST root and the OpenAPI document enumerate what the key can even see.
bash
curl -sS \ "$SUPABASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY" \ -H "Accept: application/openapi+json"
A 200 with paths like "/profiles" and "/rpc/some_function" is a map of your Data API. You can turn OpenAPI off under Project Settings → API / Data API. Turning the catalog off does not enable RLS. It only hides the index. The tables are still there if someone guesses the name.
Lock the table: grants first, then policies
Do this in a migration so staging and production stay in sync. The sequence Supabase documents is: enable RLS, revoke the default grants, grant back only what the app needs, then write one policy per operation.
sql
alter table public.profiles enable row level security; revoke all on table public.profiles from anon, authenticated; -- Signed-in users read and update their own row. Signed-out visitors get nothing. grant select, update on table public.profiles to authenticated;
Until you add a policy, a publishable-key request should see no rows. That is the safe default. Then add the policy for the operation you actually want:
sql
create policy "profiles_select_own" on public.profiles for select to authenticated using ( (select auth.uid()) = user_id ); create policy "profiles_update_own" on public.profiles for update to authenticated using ( (select auth.uid()) = user_id ) with check ( (select auth.uid()) = user_id );
A few details that matter in real apps:
auth.uid() returns the user id from the JWT, or null when nobody is signed in. A policy that says auth.uid() = user_id without a TO clause is evaluated for anon too. null = user_id is never true, so it usually fails closed — but you still pay the function call, and it is easy to write a looser expression by accident. Put to authenticated on policies that are about a signed-in user. The role check runs first.
Wrap auth.uid() in select ((select auth.uid())). Postgres can then treat it as a once-per-statement value instead of calling it per row.
If a row is meant to be public — a published post, a public profile card — say so with a second policy targeted at anon. Do not reuse the "own row" policy and hope.
sql
grant select on table public.posts to anon, authenticated; create policy "posts_public_read" on public.posts for select to anon, authenticated using ( published = true );
Writes stay off for anon because you never granted INSERT / UPDATE / DELETE and you never wrote those policies.
Deny anonymous access when you are unsure
If you found a table you do not recognize, or a scaffold table you are not ready to model, deny anon outright and come back later.
sql
alter table public.your_table enable row level security; revoke all on table public.your_table from anon; create policy "your_table_deny_anon" on public.your_table as restrictive for all to anon using (false) with check (false);
Policies are permissive by default and combine with OR. A permissive using (false) next to any later using (true) is a no-op — the true one wins. as restrictive ANDs instead, so later permissive policies cannot override it. A table with only this restrictive policy is also denied, because Postgres needs at least one permissive policy to grant access.
The revoke still matters. Grants run before RLS. If anon still has INSERT, you are one accidental permissive policy away from a write path.
Anonymous Auth users are a different role. signInAnonymously() issues an authenticated JWT with is_anonymous. auth.uid() is not null for those users. If you enabled that provider, a policy that only checks auth.uid() treats a throwaway session like a real account. The Auth posture post covers the is_anonymous claim.
Re-test after you deploy the migration
Run the same curl. You want [] or a permission error on private tables, and only the columns you chose on public ones. Then hit the table as a signed-in user — a real magic-link session, not the service role — and confirm your own row comes back.
A useful extra check is supabase test db with a pgTAP file per table. Supabase's RLS guide walks through lives_ok / throws_ok for anon and authenticated. If you do not have the suite yet, curl is enough to catch the common failure: RLS off, default grants still on, profiles readable from a phone on the train.
What hackymacky looks for
A preview scan (paste a URL, no account) fetches the HTML and JS your site already serves, looks for secret shapes and the project URL plus a publishable / anon key, then probes that key against REST / OpenAPI, RPC names, Storage, and write capability (canary columns, no persisted rows). It records capability signals — status codes, path names — not row bodies. The preview response is the score, severity counts, and the top three findings with titles redacted.
Claiming the domain unlocks the full finding list with a copy-paste fix on each one, plus Auth posture (settings, admin/users, identity tables, recover). Daily rescans, history, and the scan diff tell you if a later migration turned RLS off again. Alerts, triage, the score badge, and a shareable report sit on the same claimed project.
If you want that check on a URL you already shipped, paste it on the homepage. The curl above is still the source of truth you should keep in the repo.