Anonymous callers cannot read table rows
With the page's own anon key, every exposed table returns zero rows to an anonymous select.
- Severity
- blocker (one caps the grade at D, two at F)
- Tier
- Verified: after you prove ownership
- Category
- Security
- Fix
- Free, in full, below
- Id
- supabase-anon-row-read
What this means
Passing means that with the anon key your own page ships, every table we can find returns zero rows to an anonymous select. Failing means at least one table hands rows to anyone who copies that key out of your page source. The request goes straight to the database API, so nothing you did to your endpoints is involved.
How we test it
This check runs after you verify ownership (DNS TXT _securealy, the securealy-verify meta tag, or /.well-known/securealy.txt). For each table name found in your bundle we make one anonymous select with the page's own anon key, limit 1 and a count only; nothing is stored. Pass is an empty result or a 401 from every table. Fail is any table that returns a row, and the check is a launch blocker. A table that is public on purpose, such as a catalogue, still fails here, so the report names it and you confirm it is intended.
Why it matters
RLS off is the cause of nearly every reported vibe-coded data leak. The anon key is public by design and talks to the database API directly, never touching your endpoints, so gating cannot help on this path. In the Fix Kit's own audit, four tables created without row level security let the public key in every page's source read 493 rows and 17 business addresses straight through the database API. Public reports of apps built with AI tools leaking user data come down to this one setting more than any other.
The fix
Row level security on by default, zero policies until one is justified, and server code reading with the service key. The Fix Kit's templates/rls-baseline.sql (Module 3) does this in one re-runnable script: it enables RLS on every table in public, revokes the anon and authenticated roles' direct privileges on all tables and sequences, and changes the default privileges so a table created next month starts closed. The service role is untouched; it bypasses RLS by design and must never reach a browser.
- Create a Supabase personal access token (your avatar, Account, Access Tokens) and put it in
.env.localasSUPABASE_MGMT_TOKEN. Then run the SQL through the management API, no psql needed:
set -a; source .env.local; set +a
sql() { jq -Rs '{query: .}' | curl -s -X POST "https://api.supabase.com/v1/projects/$SUPABASE_PROJECT_REF/database/query" \
-H "Authorization: Bearer $SUPABASE_MGMT_TOKEN" -H 'Content-Type: application/json' -d @-; }
sql < MODULE-03-lock-it-down/templates/rls-baseline.sql
Stop before running it on a live project: any page that reads a table from the browser with the anon key stops working until a policy is written for it. The right answer for most apps is none; the server reads with the service key after the gate has run.
- For a table a page must read directly, add the narrowest policy. A grant without a policy opens nothing and a policy without a grant does nothing, so both lines, every time:
grant select on public.TABLE_NAME to authenticated;
create policy "TABLE_NAME: read own rows" on public.TABLE_NAME
for select to authenticated
using (user_id = auth.uid());
A table that is public on purpose uses the 4c template in the file, says so in the policy name, and is listed in templates/public-tables.txt.
- Verify from the catalogue and then with the public key itself:
echo "select tablename from pg_tables where schemaname='public' and not rowsecurity;" | sql
bash MODULE-03-lock-it-down/templates/audit.sh rls
Expect [] (the one tolerated exception is spatial_ref_sys, which PostGIS owns) and rls: 0 tables readable with the anon key. If a page went blank, it read a table directly: add the policy and note it. Do not flip a security definer view to invoker blindly; a view that serves a public page from locked tables goes blank the moment it runs as the caller.
A deeper runbook for this class is in the Fix Kit, Module 03.
Prompt for your AI
Paste this into the coding agent that built the app.
Read MODULE-03-lock-it-down/templates/rls-baseline.sql in full and list every table in the public schema, then tell me which ones the browser reads directly with the anon key (grep the client code for .from( calls). Define the sql() helper from Module 3 step 4 using SUPABASE_PROJECT_REF and SUPABASE_MGMT_TOKEN from .env.local; never print either value. Run: echo "select tablename from pg_tables where schemaname='public' and not rowsecurity;" | sql and show me the list. STOP. Show me the tables that would lose browser access and the narrowest select policy you propose for each; wait for my yes before running the baseline, because it changes a live database. After my yes, run sql < MODULE-03-lock-it-down/templates/rls-baseline.sql, then add only the policies I approved. Run `bash MODULE-03-lock-it-down/templates/audit.sh rls` once and show me the output; expected: rls: 0 tables readable with the anon key.
Grade your own URL
Every grade lists this check with its evidence for your site. Two free grades a day.