Guide

Your anon key is public. That's fine, if RLS is right.

In a Supabase app, Row Level Security is the only thing between the public key in your frontend and the rows in your database. Here is how it works and how to test it.

The anon key is meant to be seen

Every Supabase project has a key that ships to the browser: the anon key, called the publishable key in newer projects. It lets your frontend talk to the database directly through the REST API, with no server of your own in between. Anyone who opens your site can read it in the developer tools, and that is by design.

So when a scanner, including ours, sees an anon key in your public code, it is a note, not a Fix. What matters is what that key is allowed to do. The answer comes entirely from Row Level Security.

The other key, the service role key (or secret key), is different. It skips RLS completely. It belongs only on a server or in an edge function, never in code that reaches the browser. If our owner view reports a string shaped like a service role key in your frontend, rotate it in the Supabase dashboard before anything else.

What RLS does

Row Level Security is a Postgres feature. When it is on for a table, every query against that table is filtered by policies you write. A policy is a condition, checked row by row: "this row is visible if its user_id matches the signed-in user." Rows that fail the condition are simply not returned, and updates to them do nothing.

Two states cause most trouble:

  • RLS off. The table has no filter at all. Anyone holding the anon key can read every row, and usually insert, change and delete them too.
  • RLS on, policy too wide. A policy such as using (true), or one that only checks that a user is signed in, lets every signed-in user see everyone's rows. The dashboard shows RLS as enabled, so this one is easy to miss.

To find tables with RLS off, run this in the SQL editor:

select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

The two-user test

A scanner can tell you RLS is on. Only a test with two real accounts tells you whether user A can see user B's data. Run this on a staging copy, or on your live app with test accounts only.

  1. Create two test users, A and B. Sign in as B and create a row in each table that matters: a note, an invoice, a profile.
  2. Sign in as A. In the browser's developer tools, find a request to supabase.co and copy the Authorization: Bearer token. That is A's access token.
  3. Ask for B's rows with A's token:
    curl "https://<project>.supabase.co/rest/v1/invoices?select=*" \
      -H "apikey: <anon-key>" \
      -H "Authorization: Bearer <user-a-token>"
  4. You should see only A's rows. If any of B's rows appear, the policy is too wide.
  5. Repeat with no Authorization header, just the apikey. Unless the table is meant to be public, the answer should be an empty list [].
  6. Try a write: as A, PATCH one of B's rows by its id. The response should change nothing.

Do this for every table in the public schema. For a small app it takes about ten minutes.

Policies that hold up

Write one policy per action, and compare every one to auth.uid(). The with check clause stops a user writing a row that claims to belong to someone else.

alter table public.invoices enable row level security;

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

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

create policy "edit own invoices" on public.invoices
  for update to authenticated
  using (user_id = (select auth.uid()))
  with check (user_id = (select auth.uid()));

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

Wrapping auth.uid() in a select lets Postgres evaluate it once per query instead of once per row, which keeps large tables fast.

For data shared by a team, check membership through a separate table that users cannot write to themselves:

create policy "team members read projects" on public.projects
  for select to authenticated
  using (
    exists (
      select 1 from public.memberships m
      where m.team_id = projects.team_id
        and m.user_id = (select auth.uid())
    )
  );

And keep roles out of reach. If a role or is_admin column lives on a table users can update, they can usually promote themselves. Grant update only on the columns they should change:

revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;

What a passive check can and can't see

Our free check never signs in and never reads your rows. In the owner view it asks the REST API, with your public anon key, which tables it describes. That is normal, so it is reported as a note. Whether rows are readable depends on RLS, which a passive check cannot see. Storage policies, auth settings and edge functions are out of its view too.

What the $290 review covers

The Ship Safe Check is a hands-on review of one app, with your written authorization and test accounts you give us. For a Supabase app it covers:

  • The two-user test on every table, for reads and writes, signed in and anonymous
  • Policies that are on but too wide, and roles users can change
  • Storage buckets and their policies
  • Edge functions: whether they check who is calling, and which key they use
  • Auth settings: sign-up, email confirmation, redirect URLs
  • Keys in the frontend, webhooks and headers

You receive a severity-ranked report with paste-ready SQL for each finding, the retest status of each fix, and a 20-minute readout. It takes 3 business days.