Anyone can list my users table
The suspicion arrives as a sentence: anyone can list my users table. You do not need to act on suspicion, because one request settles it. Using only the URL and anon key that every visitor already holds, an unprotected table answers with its contents — sign-up emails, usernames, providers, timestamps — in a response shaped exactly like the ones your own frontend receives.
Two facts produce this state together. Your project publishes an anon key, designed to be public and shipped in every client bundle, and a table in the public schema lacks working row-level security, so nothing bounds what that key may read. Either fact alone is harmless; together they are a data leak with no error message attached.
This page stays close to the symptom: the exact request that settles the question for your project, why protection indicators stay green while rows leave the building, the policy shape that fixes it properly, and how the fix gets watched afterwards. The sibling page table-returns-data-unauthenticated covers the same failure for arbitrary tables; this one is about the users-shaped table specifically.
Rules that check this
- criticalTable readable with the anon key
ANON_TABLE_READABLE
What actually happens
Most projects hold two user-shaped tables. auth.users lives in a platform schema that PostgREST never exposes; the profile table in public — profiles, accounts, users — is the one your schema owns, usually referencing auth.users by identifier while duplicating the displayable fields. That public twin inherits the schema-wide grants to anon and authenticated that Supabase projects ship by design, because broad grants are what make the REST surface useful at all.
Whether those grants matter depends on one flag. With Row Level Security enabled, every query from anon is filtered through the table's policies, and a well-scoped policy returns nothing to a stranger. With the flag unset — the state left behind by any migration that forgot the line — the grants decide alone, and they permit reading. PostgREST then does its job faithfully: the request arrives, no policies apply, and the rows travel out under a status 200.
Why nothing errors
Every observer sees a healthy system. Your application cannot tell the difference between "the server sent these rows" and "the database judged this caller may see these rows" — most clients never exercise that distinction, so nothing fails on screen. Logs record successful GET requests, which is precisely what they are. Nothing throws, so nothing alerts.
The dashboard reports configuration at the moment you open it, and the built-in Security Advisor is worth opening — it flags RLS-disabled tables in exposed schemas plainly. Between visits, though, nothing re-tests behaviour: a migration applied on Friday lands invisibly, and the green state you remember was true when you looked and false by Monday. Deployment pipelines gate on whether SQL ran, not on what the resulting database permits.
Settle it in one request
Run this from any terminal, substituting your project's hostname and placing the anon key in an environment variable. These are the same two values your frontend ships to every visitor, and the request is a plain read — identical in kind to what the app already does on load, so it is safe to run against production:
Three responses are possible, and each means something specific. Rows returned means anyone can read the table, demonstrated rather than suspected. A permission error means policies engaged and denied the caller — the healthy shape. An empty array is genuinely ambiguous, because PostgREST answers 200 with [] both when a policy filtered every row and when the table simply has none; treat empty-but-reachable as unresolved until the table is known to hold data.
Note too what a read cannot tell you. Write exposure — inserts attributed to other users through a missing WITH CHECK, updates widened by a fallback to USING — leaves no trace in a GET. Reads prove read exposure cheaply; writes need the policy definitions themselves, which is catalog work rather than HTTP work.
curl -s "https://your-project.supabase.co/rest/v1/profiles?select=*&limit=5" \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Authorization: Bearer $SUPABASE_ANON_KEY"
Closing it properly
The repair is enablement, forcing, and one owner-scoped read policy, applied together. Scope the expression to whichever column ties a row to its owner in your schema rather than pasting a placeholder, and wrap auth.uid() in a scalar subquery so the planner evaluates it once per statement:
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.profiles FORCE ROW LEVEL SECURITY; CREATE POLICY "profiles_owner_read" ON public.profiles FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
Keeping it closed
Nothing needs rotating: the anon key did exactly what it was designed to do, and replacing it changes nothing about what policies permit. The durable boundary is the policy, verified afterwards by re-running the same request and expecting the permission error this time. Then the check needs a schedule, because the failure re-enters the way it entered — another migration, another generated table, another missing pair of lines.
RowShield runs the outside-in half of this continuously. Its probe requests tables as an anonymous caller — GET requests only, never writes — and raises findings under the ANON_TABLE_READABLE rule, naming columns and row counts while retaining no values. Scans diff between runs, so a fix that holds goes quiet and a table that becomes readable again arrives as a regression alert rather than a new problem.
RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads catalog and bucket metadata only, never the contents of your tables, and Supabase and PostgREST are referenced descriptively. Run a free audit — no account — at rowshield.dev/audit to see what an anonymous caller can list today.
Frequently asked
- Is my anon key compromised?
- No. The anon key is designed to be public and ships in every client bundle; rotating it changes nothing about what policies permit. The exposure comes from a table without working row-level security, and that is where the fix belongs.
- Does enabling RLS break my app?
- Enabling denies every row until a policy grants access, so write the owner-scoped policies first and apply both in one change. Done that way, the break window is a single deploy whose timing you chose.
- Are exposed email addresses really serious?
- Yes, and for an ordinary reason: a confirmed list of customer addresses feeds phishing that quotes real account details, and the exposure is provable by anyone. Treating the rows as innocuous understates what the rows enable.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit