RowShield
Guides

Cursor-written migrations and the RLS line that never appeared

Cursor is good at migrations. Ask for a feature and you receive plausible DDL — sensible columns, correct foreign keys, the occasional index you had forgotten to want. The failure this page covers is narrower than bad SQL: the migration is fine, and the two or three statements that decide whether the resulting table is public are simply not in the file, because the request did not mention them.

Reviewing for absence is harder than reviewing for error. A wrong line announces itself to a careful reader; a missing ENABLE ROW LEVEL SECURITY line leaves no trace in the diff to notice. This page gives the review a concrete target, a post-apply verification query that catches what eyes miss, and the remediation pattern. It complements cursor-supabase-security, which audits the quality of policies that do exist; this one is about the lines that never got written at all.

Rules that check this

Where the line goes missing

Here is a faithful shape of the artifact under discussion — competent DDL with the decisive statements absent. Read it once as a database would and once as a reviewer: nothing here is wrong enough to fail, nothing here warns anyone, and nothing here bounds who may read invoices once it ships:

Give the review a concrete target instead of a general worry. For every CREATE TABLE in a generated migration, expect either an ENABLE ROW LEVEL SECURITY statement in the same file or a stated reason for its absence. Tables intended purely for server-side access through privileged roles are the legitimate exception; everything else in the public schema is a finding waiting for its lines.

CREATE TABLE public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users (id),
  amount_cents integer not null,
  status text not null default 'open',
  created_at timestamptz not null default now()
);

CREATE INDEX idx_invoices_user_id ON public.invoices (user_id);

-- Applied successfully. Note what is not present:
-- no ALTER TABLE ... ENABLE ROW LEVEL SECURITY,
-- therefore no policies, therefore no restriction at all.

Why review misses it

Diff review optimises for changed lines, and absence produces none. A reviewer scanning the migration above sees columns, types, a reference to auth.users — evidence of care. The information "this table will be world-readable" is encoded nowhere in the file; it emerges from the interaction between the new table and grants that pre-exist on the schema. Reviews check what is written; exposure is decided by what is not.

There is also a subtler pull: the migration runs, the feature works in preview, and the loop closes on success. The tool optimises for "it works", and in fairness the model answered the question asked. Access control was nobody's question yet. Treating "the migration applied" as "the table is safe" is the gap between those two questions, and it closes only when something tests the second one explicitly.

Verify after apply, not instead of review

The catalog check converts review-from-memory into review-from-fact. Run it after applying any batch of generated migrations and it enumerates exactly what shipped: which tables exist in the exposed schema, whether RLS is on, and how many policies stand behind each. New tables appear as new rows; a table with rls_enabled false is the missed line made visible:

Keep the query somewhere near the migration workflow — a saved snippet, a make target, a CI step after apply. The value is placement rather than sophistication: it runs where forgetting it is difficult, its output is short enough to read every time, and it reads the database rather than anyone's recollection of intent.

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

What fixing looks like

The remediation is the pair of statements the migration forgot, followed by the policies that give enablement meaning. Enable without policies produces the deny-all state; force extends the rules to the table owner so future psql sessions and migrations cannot walk around them:

Scope the policy expression to the column that expresses ownership in your schema — user_id above — rather than pasting a placeholder, and name policies for their intent so the next reader inherits understanding instead of archaeology. Generated remediation earns its place here precisely because it reads your actual columns instead of guessing at conventions.

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

CREATE POLICY "invoices_owner_select"
  ON public.invoices
  FOR SELECT
  TO authenticated
  USING ((SELECT auth.uid()) = user_id);

Making the check routine

Two habits hold the line without demanding perfection from every prompt. First, fold the catalog query into your post-migration ritual — it costs seconds and reads truth rather than intent. Second, let machinery carry the schedule: RowShield runs the same class of audit continuously, evaluates rules including RLS-disabled, and exits non-zero in CI when findings meet a threshold you choose, so a batch of generated migrations that ships a naked table fails visibly rather than silently.

RowShield reads pg_catalog metadata only, never your data, and is an independent product unaffiliated with and not endorsed by Supabase. Once the missing lines exist and policies are present, the deeper question of policy quality — always-true conditions, writes without WITH CHECK, unindexed predicates — is the territory covered by our cursor-supabase-security audit page.

Frequently asked

Should I stop letting the model write migrations?
The pragmatic position is to keep the speed and add verification: generated migrations applied behind a catalog check that runs after every apply catch the missing-lines class without slowing anything down. Reviewing prose harder does not scale; querying the resulting database does, because the catalog cannot be fooled by confident wording.
Does the Supabase advisor flag these tables?
Yes — the dashboard advisor reports RLS-disabled tables in exposed schemas and is worth opening after large batches. It reflects state when opened; pairing it with a scheduled check covers the interval, which is where generated migrations tend to land unnoticed.
Is there a way to gate this automatically?
RowShield's CLI runs the full audit against a connection string and exits 0 when clean, 1 at your chosen severity threshold and 2 when the scan itself failed — so a CI step after migrations turns a forgotten RLS line into a red pipeline rather than a discovery months later.

Check your project in about ten seconds

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

Run the free audit
cursor supabase migration rlscursor generated migration row level securityreview ai supabase migrationsupabase alter table enable rls