Skip to content
Throttle Bird

Logbook · Launch notes

The anon key is not the secret

Supabase anon keys belong in the browser. service_role keys and a policy of USING (true) do not. Which keys belong in the browser, and how to tell if your tables are open.

2026-08-12 · Throttle Bird

Which Supabase keys belong in the browser, which don't, and how to tell if your tables are open.

The first time a vibe-coded app talks to Supabase, the model usually drops createClient(url, key) into a client component and moves on. The app works. The key is now in every browser that loads the page. That is not automatically a breach. It depends which key you pasted.

Two keys, two jobs

The anon key (and the newer publishable key, sb_publishable_…) is designed to ship to the browser. It identifies your project. It does not, by itself, grant access to every row. Row level security is supposed to do that.

The service_role key, and the newer secret key (sb_secret_…), skip row level security. They are a database superuser for the API. They belong in a server environment variable. If either one is in a client bundle, anyone who clicks View Source can read and write the tables the key can see. Rotate it. Do not argue with the finding.

A JWT in the bundle is easy to classify. It is three base64 chunks. The middle one is JSON. If role is service_role, you shipped the wrong key. If role is anon, you shipped the public one, and the next question is whether the policies are real.

The fix that makes it worse

Generated apps hit this sequence a lot:

  1. The client uses the anon key.
  2. A query fails because RLS is on and there is no policy.
  3. The model "fixes" it. Sometimes it swaps in the service role so the error goes away. Sometimes it adds a policy:
create policy "public read" on public.notes
  for select using (true);

using (true) for the anon role means the table is world-readable. The error is gone. So is the boundary. An empty table still returns HTTP 200 and []. Empty is not protected. The next insert is public too.

The other version of the same bug is a policy the model writes once and never narrows: for all using (true) with check (true). That is a public read-write API wearing a Postgres costume.

What to check, without dumping the table

Open the project URL that is already in your bundle, https://<ref>.supabase.co. With the anon key in the apikey header, GET /rest/v1/ returns an OpenAPI document listing relations the key is allowed to see. You do not need to guess table names.

Then GET /rest/v1/<table>?select=*&limit=1.

  • 401 or 403. The public key cannot read it. Good, assuming you meant that.
  • 200 and a row. Anyone with the anon key, which is everyone, can read at least that much. Turn on RLS and replace the policy.
  • 200 and `[]`. The read was allowed and the table is empty. Treat it as open.

Do not paste row JSON into a chat while you fix it. The fact of the 200 is the bug. The contents are user data.

Throttle Bird does this only when the page already shipped the URL and a public key, and only for a few tables the schema itself listed. It stores the status, not the row.

A policy that matches the product

Enable RLS first. In current Supabase projects, new tables in public often have RLS on and no policies, which means the anon key sees nothing. That default is safe. The danger is turning it off, or adding a pass policy, to make a demo query succeed.

For rows a signed-in user owns:

alter table public.notes enable row level security;

create policy "read own notes" on public.notes
  for select to authenticated
  using (auth.uid() = user_id);

create policy "write own notes" on public.notes
  for insert to authenticated
  with check (auth.uid() = user_id);

Add update and delete the same way if the product needs them. Use the service role only in a Route Handler or Server Action that already checked who the user is, and only for work the user is not allowed to do with their own JWT (webhooks, admin jobs, bypassing RLS on purpose).

If a table is genuinely public — a changelog, a list of plans — say so in the policy name and keep writes locked. "Public read" is a product decision. It should not be the fix for a red error in the terminal.

A prompt you can paste

The browser is using a Supabase client. Confirm the key in client code is the anon or publishable key, never service_role or sb_secret_. If a service key is present, remove it and tell me to rotate it; do not print the key. Enable RLS on every table the client queries. Replace any policy with USING (true) or WITH CHECK (true) unless that table is deliberately public, and name the policy so that intent is obvious. User-owned rows should use auth.uid() = user_id. Do not switch the client to the service role to silence an RLS error.

Check your own app

Paste your URL to run these checks automatically. Free, results in under a minute.

Scan my app free