My API returns data with no login at all
"My API returns rows with no login at all" is the observation, and it is usually correct: the request carried no session cookie and no user token — only the anon key that ships with every page load. That key identifies the caller as unauthenticated; it does not authorise anything by itself. Whether rows come back is decided per table by Row Level Security, or silently skipped where it is absent.
PostgREST maps the anon key to a database role called anon. Signed-in callers present a JWT carrying the authenticated role instead. Everything a stranger can reach, therefore, is exactly what the anon role can reach — a property worth internalising, because it converts a vague worry into a checkable one: what does anon resolve to on each of your tables?
This page covers the general case — any endpoint answering unauthenticated requests. For the users-and-profiles flavour specifically, supabase-users-table-readable walks that table end to end. Here the focus is the three response shapes, the configuration states behind them, and the smallest correct fix.
Rules that check this
- criticalTable readable with the anon key
ANON_TABLE_READABLE
Who is calling when nobody signs in
Every request to the REST endpoint carries an apikey header, because the client library attaches it automatically. When no user is signed in, that header is the whole identity: PostgREST hands the query to Postgres as the anon role. Any table whose policies grant anon — or whose RLS is disabled, leaving pre-existing schema grants in charge — will answer such a request with data.
This design is deliberate and mostly harmless when policies exist. Anonymous access powers public listings, landing-page content and pre-signup flows on countless projects. The failure is not that anon can knock; it is that on some table, nothing stands between the knock and the contents.
Three responses, and only one of them is clean
Ask one endpoint directly, with no session and no stored login — the curl below uses only the public key, and substituting your hostname and a real table name reproduces the experiment. Choose a table you believe holds private rows; the point is to test the interesting case rather than a lookup table chosen because it feels safe.
curl -s "https://your-project.supabase.co/rest/v1/tasks?select=*&limit=5" \ -H "apikey: eyJhbGciOi…REDACTED" \ -H "Authorization: Bearer eyJhbGciOi…REDACTED"
Reading the shapes without wishful thinking
Rows mean the endpoint answers strangers with data — the finding, demonstrated. A permission error means the policies engaged and refused the caller, which is the healthy outcome. An empty array is the trap: a policy that filters everything and a table with nothing in it produce byte-identical responses, so silence proves nothing until you know the table is populated. Honest tooling records reachable-but-empty as unresolved rather than green, and so should you.
Note what a read cannot tell you. Write exposure — inserts attributed to other users through a missing WITH CHECK clause, updates widened by a fallback to USING — leaves no trace in a GET request. Reads prove read exposure cheaply; writes need the policy definitions themselves, which is catalog work rather than HTTP work.
Why the dashboard looked fine
Configuration and behaviour are different facts. RLS enabled is a flag; policies are additional grants layered onto a default denial; and a policy whose condition reduces to constant true grants every row while leaving the flag — and every indicator built on it — untouched. The classic states fail differently too: RLS disabled leaks loudly, RLS enabled with zero policies denies everything, and a tautological policy leaks quietly behind a protected-looking surface.
The built-in advisor catches several of these states and deserves its reputation; it is a page you open, reporting what is true when opened. An endpoint that starts answering strangers after an unattended migration will not prompt anyone to open it. That interval — between looks, when schemas move — is where unauthenticated reads establish themselves.
Bounding the caller, then keeping it watched
Bound the anonymous identity with the standard triple, then verify as the stranger rather than as an administrator — privileged sessions bypass row-level security entirely and will reassure you about nothing. Scope the expression to whatever column expresses ownership in your schema:
Re-run the unauthenticated request afterwards and expect the permission error. Resist widening the policy if a legitimate feature breaks — features that genuinely need broader reads deserve their own narrowly written policy, not an edit that quietly reopens the whole table to every caller holding the public key.
RowShield automates exactly this experiment on schedule: its probe enumerates the exposed surface and attempts reads as an anonymous caller, GET requests only, refusing private network addresses before connecting and re-checking on each redirect hop. Endpoints that answer raise findings under ANON_TABLE_READABLE, reported by column name and count with no values retained, and alerts fire on transitions — created, regressed, resolved — so a stable project stays quiet.
RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads catalog and bucket metadata only, never rows, and Supabase, Postgres and PostgREST appear descriptively throughout this site. Run a free audit — no account — at rowshield.dev/audit and watch what an unauthenticated caller receives from your endpoints today.
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; ALTER TABLE public.tasks FORCE ROW LEVEL SECURITY; CREATE POLICY "tasks_owner_read" ON public.tasks FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
Frequently asked
- Could the empty array mean I am safe?
- Not reliably. A policy filtering every row and a genuinely empty table return the same 200 with [], and a table that is empty today can fill next month with no further change. Record it as unresolved and re-test once data exists.
- Should I hide the anon key from my frontend instead?
- Removing it breaks the application outright, and obfuscation only hides it from polite eyes. The architecture assumes a public key bounded by row-level security; the durable fix is policies, not secrecy.
- Does this prove someone has been reading my data?
- No — it proves the door is open, not that anyone walked through it. Access logs on the platform side are where actual traffic shows up; the finding itself concerns reachability, which needs no visitor to be true.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit