RowShield
Guides

World-readable tables in a Lovable build — and the one request that proves it

"World-readable" has a precise meaning here: a table in your public schema answers a request carrying no credential beyond the anon key with actual rows. That key sits in every bundle your Lovable app serves, so the practical audience for such a table is anyone who can open developer tools anywhere on earth. The state is silent, stable, and completely ordinary in generated builds.

This page stays close to ground level: what makes a generated table world-readable, the single HTTP request that settles the question for any given table, how to read the three possible responses without wishful thinking, and the SQL that closes the door. If you want the broader map of gaps these builds ship with, the cluster page lovable-supabase-security-gaps covers the taxonomy; the free automated check lives on lovable-supabase-data-leak.

Rules that check this

How a table becomes world-readable

The sequence needs no mistake, only an omission. A prompt produces a migration with CREATE TABLE and the columns the UI needs. Nothing in that migration enables Row Level Security, because the conversation was about features. Supabase projects grant the anon role broad rights on the public schema by design, so with RLS off, the grants are sufficient and the table answers everyone.

Nothing marks the moment. The migration applies cleanly, the preview populates, the feature ships. Weeks later the table holds real user data and the configuration has not changed since it was empty. World-readability is not a state anything switches into later; it is the default posture of a created table whose one protective line never arrived.

One request proves it

You do not need a scanner to know. Every Supabase project publishes its URL and anon key — the URL is public knowledge and the key ships to every visitor — so the following request uses exactly the access a stranger already has. Set the two variables from your project settings and run it against any table name:

Handle both values without ceremony: this pair crosses the network on every page load the app performs, and the request below is a read, indistinguishable in kind from the ones the frontend already issues. It can be run against production safely, because no matter how the policies are configured it cannot insert, update, or delete anything.

curl -s "$SUPABASE_URL/rest/v1/profiles?select=id,username,created_at&limit=5" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

Reading the three responses

Rows come back: the table is world-readable, demonstrated rather than suspected, and the finding includes column names because you chose them in the select. A permission error comes back: policies engaged and denied the caller, which is the healthy shape. An empty array comes back: genuinely ambiguous, because PostgREST answers 200 with [] both when a policy filtered every row and when the table simply has none.

That ambiguity deserves respect, because it is where self-audits go wrong. People see [] and relax; the table may be empty today and full of signups next month with no further change. Honest tooling records empty-but-reachable as its own state rather than green. RowShield probes tables this way — GET requests only, never writing, refusing private network addresses before connecting — and reports leaked content by column name and count, retaining no values.

The fix, and proving it held

Closing the door takes three statements, and the third is where the actual access decision lives. Scope the policy to whatever column expresses ownership — owner_id, user_id, created_by — rather than copying a placeholder, and wrap auth.uid() in a scalar subquery so the planner evaluates it once per statement instead of once per row:

Pick the expression from the schema rather than from habit: the column that genuinely ties each row to its owner is the one worth filtering on, and naming policies after their intent saves the next reader an archaeology session. Where several tables share one ownership column the same shape repeats per table — Postgres offers no project-wide switch for authorisation, and pretending otherwise is how placeholders end up in production.

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);

Proving it held

Re-run the same request afterwards. The expected response is the permission error or the empty array — and now you know which, because you know whether the table holds rows. Keep the curl somewhere runnable, because the useful question over the life of the app is not "is it fixed today" but "did anything reopen it": another prompt, another migration, another table cloned from an unsafe template.

That recurring question is what RowShield schedules. Each scan re-runs the probe and diffs against the last run: a newly readable table raises a created alert, a fix that regressed raises a stronger regressed alert, and resolved findings go quiet. RowShield is an independent product, unaffiliated with and not endorsed by Supabase, and it reads catalog metadata plus bucket metadata only — never the contents of your tables.

Frequently asked

Does an empty response mean my table is safe?
Not reliably — PostgREST returns 200 with an empty array both when a policy filters every row out and when the table contains no rows at all. Treat empty-but-reachable as unresolved, note the table, and re-check once it holds data, because nothing will announce the change.
Is running this request against my own app risky?
No. It is a read-only GET identical to what any visitor could issue, and it cannot insert, update or delete anything. Tools in this space restrict themselves to GET, HEAD and OPTIONS precisely so the diagnostic can never become the incident.
Can I just delete the anon key from my frontend instead?
The anon key is required for your app to talk to Supabase at all, including for signed-in users, so removing it breaks the application rather than the exposure. The design assumption is a public key bounded by Row Level Security; the fix is policies, not secrecy.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
lovable app data leaklovable supabase world readable tableanon key read table supabasetest lovable app permissions