PL-001criticalLab

Anonymous visitor can read personal data

Anyone with your app's public key can read emails, phone numbers or addresses.

Severity
Critical
Who can exploit it
Anyone on the internet
Found by
Free RLS check (static) and the full lab check
Lab proof
Automatic: anon, stranger and owner reads

What it is

Every Supabase app ships a public key (the anon key) inside its web pages. That is by design: the key is meant to be public, and row level security (RLS) policies decide what it can read. PL-001 means a table holding personal data, such as emails, phone numbers, addresses or dates of birth, has a read policy that lets the anon role through without checking who is asking.

Nobody needs to break in. Anyone who opens your site can copy the key from the page and ask the database for every row. The usual cause is a policy written as using (true) with no to clause, which applies to every role, including logged-out visitors.

In our lab study, 70 of 318 rebuilt apps (22%) let anyone with the public key read personal data, through this pattern or PL-008 (PatchLabs lab study, n=318, Nov 2026).

Example of vulnerable code

Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.

supabase/migrations/…_create_profiles.sql (vulnerable)
create table public.profiles (
  id uuid primary key references auth.users (id) on delete cascade,
  full_name text,
  email text,
  phone text
);
alter table public.profiles enable row level security;

-- No "to" clause: this applies to every role, including anon.
create policy "Profiles are viewable by everyone"
  on public.profiles for select
  using (true);

Check it yourself

Run these in the Supabase SQL editor of your own project. The second query reads the table exactly as a logged-out visitor would, inside a transaction that is rolled back, so nothing changes.

check-pl-001.sql
-- 1. Read policies that apply to logged-out visitors and don't check the user
select tablename, policyname, roles, qual
from pg_policies
where schemaname = 'public'
  and cmd in ('SELECT', 'ALL')
  and roles && array['anon', 'public']::name[]
  and coalesce(qual, '') not ilike '%auth.uid()%';

-- 2. Read a table as an anonymous visitor (nothing is changed)
begin;
set local role anon;
select count(*) from public.profiles;  -- more than 0 means anyone can read it
rollback;

Tables with RLS switched off never appear in pg_policies. Check those with PL-008.

The fix PatchLabs writes

When the rows belong to individual users, PatchLabs replaces the open policy with an owner-only one. Using (select auth.uid()) rather than auth.uid() lets Postgres evaluate it once per query instead of once per row.

supabase/migrations/…_patchlabs_leak_fix.sql
-- supabase/migrations/<timestamp>_patchlabs_leak_fix.sql
drop policy "Profiles are viewable by everyone" on public.profiles;
create policy "Users can view own profile"
  on public.profiles for select
  to authenticated
  using ((select auth.uid()) = id);

When a public page genuinely needs some columns (a profile page showing a name and avatar), the fix keeps the rows readable and withholds the personal columns from logged-out visitors:

alternative: hide personal columns
-- Visitors keep the public columns; contact details are withheld.
revoke select on public.profiles from anon;
grant select (id, full_name) on public.profiles to anon;
-- The app must now ask for those columns by name: select('id, full_name').
-- select('*') will fail for logged-out visitors, so the report lists that code change.

Signed-in users are a separate question. If anyone can sign up, see PL-002.

Every fix arrives as a migration file (plus any code change it needs, noted in the report) for your engineer to approve. Download it, or commit it on GitHub in one click from the report page. Automatic pull requests need our GitHub App, which is coming.

The proof it runs

lab proof · public.profiles
[BEFORE]  anon      → 2 rows        LEAK
          email •••••@••••• phone +•• ••••
[FIX]     owner-only select policy
[PROVE]   anon      → 0 rows        DENIED
          stranger  → 0 rows        DENIED
          owner     → 1 row, own    OK
[DONE]    lab proof passed
provenIllustrative output for the example above · fake rows

This is the check every PatchLabs fix must pass. The lab rebuilds your database from supabase/migrations, seeds fake rows for two users, then reads each flagged table as the anonymous visitor, a signed-in stranger and the owner. A fix only counts as proven when the visitor and the stranger read neither the private rows nor the personal columns, and the owner still reads their own row, personal columns included.

When the pattern is intended

Some public data is meant to be public. A shop's own phone number, a venue's address or a business's contact email on its listing page are not anyone's private data. In our fix study, 4 of 24 flagged apps (about 1 in 6) turned out to be exactly this, and the agents spotted it themselves (n=24, 5 Nov 2026).

When the fix agent judges a table to be public by design, the report says so, explains why, and asks you to confirm in plain English with a recommended answer. If you confirm, the table is remembered as intended and won't alert again.

Supabase linter

0024 permissive_rls_policy
The closest lint, but it does not fire here: by design it skips SELECT policies, because public reads are often intended.
0023 sensitive_columns_exposed
Only fires on tables with RLS switched off, and its column list does not include email, phone or address.

Based on the linter's source code and its maintainers' review notes, checked Nov 2026. This is why behaviour testing matters: the lint is right that a public read can be intended, so only knowing what the table holds and who should see it settles the question.

Guides

Free check

Does your app have PL-001?

Paste your public repo. We rebuild the database from your migrations, test it as a visitor, a stranger and the owner, and hand you the proven fix.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.