RowShield
Guides

The Supabase security gaps Lovable apps ship with

Two facts ship together when you build this way. The first is that Lovable turns a prompt into a working application on a real Supabase project, often within an afternoon. The second is that the project exposes its tables through PostgREST to anyone holding the public anon key. Neither fact is a defect: one is the product working as intended, the other is Postgres behaving exactly as configured.

Between them sits one mechanism, Row Level Security, which decides whether that public key can read your rows. Prompt-driven builders rarely produce policies unprompted, because a demo renders correctly whether or not any policy exists. The tool optimises for "it works", and in the preview loop it does. Policies are what happens after the working demo — the step with no visible failing state until something draws attention to it.

This page is the map for that territory: what actually ships open, why nothing errors along the way, checks you can run yourself in minutes, the SQL that fixes things properly, and how the state gets monitored so the next prompt cannot quietly undo your work. For the free URL-only check there is a separate page, lovable-supabase-data-leak, and lovable-app-world-readable-tables covers the single-request proof in depth.

Rules that check this

What actually ships open

Three distinct end states account for nearly everything, and they fail differently enough that treating them as one "RLS problem" produces wrong fixes. RLS switched off entirely on a table in the public schema. RLS enabled with a policy whose condition is a constant true. And RLS enabled with zero policies behind it. A Lovable build can land in any of the three, usually within its first week of prompts, sometimes within a single session.

The disabled case is mechanical. A generated migration creates a table; the migration contains no ENABLE ROW LEVEL SECURITY line, because nothing in the conversation asked for one. Supabase projects grant broad SELECT rights on the public schema to the anon role by design — that is what makes PostgREST useful at all. With RLS off, those pre-existing grants decide everything, and every row answers an unauthenticated request.

The tautological case arrives when something needs to work now. A preview shows an empty list, the model responds with a permissive policy to unblock it, and USING (true) enters the project. RLS is enabled from that moment, so dashboards report the table as protected, while the policy grants every row of the table to every role it names. Protection exists as configuration and fails completely as behaviour.

The third case is the inverse failure. Someone — an advisor prompt, a checklist, a review comment — enables RLS on the generated tables and stops there. Postgres now denies every row to every non-owner role, because policies are pure additions to a deny-by-default system. The data becomes unreachable from the client: lists render empty, forms fail without errors, and the app looks broken rather than secure, which few people read as progress.

Why nothing errors

Every step along the way succeeds. Migrations run and return success, because a missing policy line is not a syntax error. Deploys complete. The preview environment renders data, because during development the very openness that will later matter is what makes the demo work. At no point does a red message appear asking whether strangers should read the profiles table.

Your application cannot catch it either. Reads through the anon session return exactly the shape the frontend asked for, and most clients never distinguish "the server sent me these rows" from "the database judged that I may see these rows". An app exercising its own access control would need to make requests without credentials deliberately — something generated frontends almost never do on their own initiative.

The dashboard reports configuration at the moment you look at it, and the built-in advisor is worth opening — it flags several of these states honestly. But a point-in-time page cannot watch the interval between visits, and prompt-driven development concentrates change into that interval. The fourth prompt after a clean review can add a table nobody re-reads, which is a drift problem rather than a first-scan problem.

Deploy pipelines close the list, and they gate on the wrong property entirely. Migrations apply or they fail; a batch containing three tables and zero policies applies flawlessly. The pipeline's only question — did the SQL run — has the answer yes. Whether the resulting database honours the access model the team believes in is a question no step in the chain asks, so nothing in the chain answers it.

Ask the catalog, then PostgREST

The catalog knows the posture of every table directly, and reading it requires nothing beyond a connection string. This query lists every table PostgREST exposes in the public schema, whether RLS is enabled, and how many policies stand behind it — the three numbers that decide everything else:

A row with rls_enabled false is a finding regardless of what the table holds, because the anon key is public and the schema grants already exist. A row with rls_enabled true and policy_count zero means the deny-all state: nothing leaks, but the table is dead weight until policies arrive. Rows carrying policies still need their expressions read, because a constant true hides inside a healthy-looking list.

Configuration answers half the question; behaviour answers the rest. With your project URL and anon key, a GET against /rest/v1/<table> returns rows when exposed, an empty array when filtered or empty, and a permission error when policies engage. The empty array is genuinely ambiguous — filtered versus merely unfilled are indistinguishable from outside — which is why honest tools record reachable-but-empty as its own state rather than rounding it to green.

SELECT n.nspname AS schema,
       c.relname AS table_name,
       c.relrowsecurity AS rls_enabled,
       (SELECT count(*)
          FROM pg_catalog.pg_policies p
         WHERE p.schemaname = n.nspname
           AND p.tablename = c.relname) AS policy_count
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
  AND n.nspname = 'public'
ORDER BY c.relname;

What fixing looks like

The repair pattern is identical whatever produced the gap: enable the machinery, force it against the owner, then write policies scoped to the column that expresses ownership in your schema. Enablement without policies produces the deny-all state; skipping FORCE leaves a hole for migrations and psql sessions, which run as the table owner:

Two details decide whether the repair is real. FORCE ROW LEVEL SECURITY extends the rules to the table owner, closing the path where privileged sessions bypass every policy; and the policy expression should reference the column that genuinely expresses ownership in your schema, because a placeholder copied from documentation produces protection-shaped SQL that matches nothing. Remediation generated from your actual columns avoids both traps.

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.projects FORCE ROW LEVEL SECURITY;

CREATE POLICY "projects_owner_select"
  ON public.projects
  FOR SELECT
  TO authenticated
  USING ((SELECT auth.uid()) = owner_id);

CREATE POLICY "projects_owner_insert"
  ON public.projects
  FOR INSERT
  TO authenticated
  WITH CHECK ((SELECT auth.uid()) = owner_id);

Staying fixed

RowShield automates exactly the steps above on every scan: the catalog audit reads pg_catalog metadata only and evaluates rules covering RLS disabled, always-true policies and enabled-with-no-policies states, while the probe asks PostgREST what the anon key can actually read using GET requests alone. Findings are diffed between scans, so a table appearing without policies arrives as an alert naming what changed, and a fix that regresses is labelled a regression rather than a new problem. Alerts fire on transitions only, so a broken project pages once instead of hourly, and retried scans stay idempotent.

Scan frequency follows the plan — daily on Free, hourly on Indie, every fifteen minutes on Team — while findings and remediation SQL are never withheld on any tier. RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads metadata, never rows, and Supabase, Postgres and PostgREST are referenced descriptively throughout this site.

Frequently asked

Is this a Lovable problem or a Supabase problem?
Neither, precisely. The failure class belongs to any workflow that creates Supabase tables faster than it writes policies — Bolt.new, Cursor, Claude Code sessions and hand-written code all reach the same states. The tools optimise for a working result; the policies are the discipline that comes afterwards, whichever tool wrote the schema.
Will enabling Row Level Security break my app?
It denies every row to non-owner roles until a policy grants access, so yes, briefly, unless the policies ship in the same change. Write them from your real columns first, apply enable-and-grant together, and the break window shrinks to a single deploy that you scheduled.
Should I just rotate the anon key?
No — the anon key is designed to be public and ships in every bundle that talks to your backend. Rotating it changes nothing about what an anonymous caller may read. The bounding mechanism is Row Level Security on each exposed table, and that is where the work belongs.
Does the Supabase dashboard catch these gaps?
The built-in Security Advisor flags several of these states and is worth opening today. It reports what it finds at the moment you open it; continuous monitoring exists for the interval between openings, when new prompts add tables and previously fixed policies quietly regress.

Check your project in about ten seconds

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

Run the free audit
lovable supabase securitylovable supabase security gapslovable app supabase policiessupabase security ai built app