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.
- 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.
- Sign in as A. In the browser's developer tools, find a request to
supabase.coand copy theAuthorization: Bearertoken. That is A's access token. - 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>" - You should see only A's rows. If any of B's rows appear, the policy is too wide.
- Repeat with no Authorization header, just the
apikey. Unless the table is meant to be public, the answer should be an empty list[]. - Try a write: as A,
PATCHone 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.