// docs / baas security / supabase rls scanner
Supabase RLS scanner: find tables with missing or broken row-level security
Row-level security (RLS) is the only thing standing between your customers' data and the internet when you ship a Supabase-backed app. AI coding tools generate RLS-shaped code that compiles, ships, and silently leaks data โ tables created without RLS enabled, policies that read but never restrict, predicates that compare a column to itself. This article shows what a Supabase RLS scanner can prove from the outside, the four broken-RLS shapes that show up in vibe-coded apps, and how to scan your own deployment in under a minute.
What an external RLS scan can prove
A passive RLS scan runs against the PostgREST endpoint that Supabase exposes at <code>https://[project].supabase.co/rest/v1/</code>. It uses only the publishable <code>anon</code> key โ the same key your browser uses โ and probes for table-list metadata, anonymous reads, and anonymous writes. It never authenticates as a user and never touches service-role privileges. Anything it can do, an unauthenticated attacker on the internet can do.
From outside the database, a scanner can confirm the following with high confidence:
- <strong>RLS is disabled on a table.</strong> PostgREST returns rows for an anonymous <code>SELECT</code> when RLS is off or when a policy permits it. Either case is a finding.
- <strong>The anonymous role can list tables.</strong> A <code>GET /rest/v1/</code> with the anon key returns the OpenAPI schema for every table that the <code>anon</code> role has any privilege on. AI-generated apps frequently grant <code>USAGE</code> on the schema and <code>SELECT</code> on every table, which exposes the full schema map even when RLS denies the actual reads.
- <strong>The anonymous role can insert.</strong> A probing <code>POST</code> with a guess at the column shape will succeed if RLS doesn't have an <code>INSERT</code> policy denying it โ even if <code>SELECT</code> is locked down.
- <strong>The service-role key is in the browser bundle.</strong> Adjacent to RLS: if a scanner finds <code>SUPABASE_SERVICE_ROLE_KEY</code> or any JWT with <code>role: service_role</code> in the JavaScript bundle, RLS is moot โ the holder of that key bypasses every policy.
What an external scan cannot prove
Be honest about the scanner's boundaries. An external RLS scan cannot read your <code>pg_policies</code> table, your migration files, or the exact predicate of any policy. It infers from black-box behaviour, which means it will sometimes report a <em>finding</em> that turns out to be intentional public data (a marketing newsletter table, a public product catalog). The FixVibe report flags these as <em>medium confidence</em> when the scanner cannot disambiguate intent โ review the table name and decide.
The four broken-RLS shapes AI tools produce
When you point Cursor, Claude Code, Lovable, or Bolt at Supabase, the same four broken-RLS patterns emerge across thousands of apps. Each one passes type-check, compiles, and ships:
Shape 1: RLS never enabled
The most common failure mode. The migration creates the table but the developer (or the AI tool) forgets <code>ALTER TABLE ... ENABLE ROW LEVEL SECURITY</code>. PostgREST happily serves the entire table to anyone with the anon key. <strong>Fix:</strong> <code>ALTER TABLE public.[name] ENABLE ROW LEVEL SECURITY; ALTER TABLE public.[name] FORCE ROW LEVEL SECURITY;</code>. FORCE is non-optional โ without it the table owner (and any role with table ownership) bypasses RLS.
ALTER TABLE public.[name] ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.[name] FORCE ROW LEVEL SECURITY;Shape 2: RLS enabled, no policies
A more subtle failure. RLS is enabled but no policies are written. The default in PostgreSQL is <em>deny</em>, so authenticated users see nothing โ and the developer adds <code>USING (true)</code> to make the app work, which permits everyone to read everything. <strong>Fix:</strong> write a policy that scopes by <code>auth.uid()</code>: <code>CREATE POLICY "select_own" ON public.[name] FOR SELECT USING (auth.uid() = user_id);</code> and a matching INSERT/UPDATE/DELETE policy.
CREATE POLICY "select_own"
ON public.[name]
FOR SELECT
USING (auth.uid() = user_id);Shape 3: Policy compares column to itself
A copy-paste artefact. The developer writes <code>USING (user_id = user_id)</code> โ which is always true โ instead of <code>USING (auth.uid() = user_id)</code>. Type-checks pass; the policy permits every row. <strong>Fix:</strong> always compare a column to a function call (<code>auth.uid()</code>, <code>auth.jwt()->>'org_id'</code>, etc.), never to itself or to a constant.
Shape 4: Policy on SELECT but not on INSERT/UPDATE
The developer locks down reads but forgets writes. RLS policies are per-command. <code>FOR SELECT</code> protects reads only; an anonymous client can still <code>INSERT</code> if no policy denies it. <strong>Fix:</strong> author a policy per command, or use <code>FOR ALL</code> with explicit <code>USING</code> and <code>WITH CHECK</code> clauses.
How the FixVibe Supabase RLS scanner works
The <code>baas.supabase-rls</code> check runs in three stages, each with explicit confidence levels:
- <strong>Stage 1 โ fingerprint.</strong> The scanner crawls the deployed app, parses its JavaScript bundle, and extracts the Supabase project URL and anon key from the runtime configuration. No DNS guessing, no brute force โ it reads what the browser reads.
- <strong>Stage 2 โ schema discovery.</strong> A single <code>GET /rest/v1/</code> with the anon key returns the OpenAPI schema for every table the anon role can see. The scanner records table names but does not read row data at this stage.
- <strong>Stage 3 โ read and write probes.</strong> For each discovered table, the scanner issues one anonymous <code>SELECT</code> with <code>limit=1</code>. If rows return, RLS is permissive. The scanner stops there โ it does not enumerate rows, does not paginate, does not modify data. INSERT probes are gated behind verified domain ownership and explicit opt-in; they never fire against unverified targets.
Each finding ships with the exact request URL, response status, response shape (header-only), and the table name. The AI fix prompt at the bottom of the finding is a copy-paste SQL block that you run in the Supabase SQL editor.
What to do when the scanner finds something
Every RLS finding is a runtime emergency. Public PostgREST endpoints get scanned by attackers in minutes. The remediation sequence is mechanical:
- <strong>Audit every table.</strong> Run <code>SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';</code> in the Supabase SQL editor. Any row with <code>rowsecurity = false</code> is a problem.
- <strong>Enable RLS on every public table.</strong> Default to <code>ENABLE ROW LEVEL SECURITY</code> and <code>FORCE ROW LEVEL SECURITY</code> on every table created โ make it a migration template.
- <strong>Author policies command-by-command.</strong> Don't use <code>FOR ALL USING (true)</code>. Write explicit policies for SELECT, INSERT, UPDATE, DELETE โ each one scoped to <code>auth.uid()</code> or an org-id column from <code>auth.jwt()</code>.
- <strong>Verify with a second account.</strong> Sign up as a different user, attempt to read another user's records via the REST API directly. If the response is <code>200</code>, the policy is broken.
- <strong>Re-scan.</strong> After applying the fix, re-run a FixVibe scan against the same URL. The <code>baas.supabase-rls</code> finding should clear.
-- Audit every table for missing RLS. Run in the Supabase SQL editor.
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY rowsecurity, tablename;How this compares to other scanners
Most generic DAST tools (Burp Suite, OWASP ZAP, Nessus) do not know what PostgREST is. They will crawl your app, ignore the <code>/rest/v1/</code> path, and report on the HTML pages they do understand. <strong>Snyk</strong> and <strong>Semgrep</strong> are static-analysis tools โ they find migration files in your repo with missing RLS calls, but they cannot prove the deployed database is misconfigured. FixVibe sits in the gap: passive, BaaS-aware, focused on what an unauthenticated attacker can prove from the public URL.
Frequently asked questions
Will the scanner read or modify my data?
No. Passive scans issue at most one <code>SELECT ... limit=1</code> per discovered table to confirm whether RLS permits anonymous reads. The scanner records the response shape, not the row contents. INSERT, UPDATE, and DELETE probes are gated behind verified domain ownership and never run against unverified targets.
Does this work if my Supabase project is paused or on a custom domain?
Paused projects return <code>503</code> on every request โ the scanner reports the project as unreachable. Custom domains work as long as the deployed app still loads the Supabase client SDK in the browser; the scanner extracts the project URL from the bundle either way.
What if my anon key is rotated or my publishable key changes?
Re-run the scan. The scanner re-extracts the key from the current bundle on every run. Rotation invalidates only the previous report, not the policy state of the database.
Does the scanner check the new Supabase publishable-key model (<code>sb_publishable_*</code>)?
Yes. The detector recognises both legacy <code>anon</code> JWTs and the newer <code>sb_publishable_*</code> keys and treats them identically โ both are intended to be public and both leave RLS as the only line of defence.
Next steps
Run a free FixVibe scan against your production URL โ the <code>baas.supabase-rls</code> check is enabled on every plan including the free tier. For a deeper read on what else can leak from a Supabase project, see <serviceKeyArticle>Supabase service role key exposed in JavaScript</serviceKeyArticle> and <storageArticle>Supabase storage bucket security checklist</storageArticle>. For the umbrella view across all BaaS providers, read <baasScannerArticle>BaaS misconfiguration scanner</baasScannerArticle>.
