// 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 https://[project].supabase.co/rest/v1/. It uses only the publishable anon 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:
- RLS is disabled on a table. PostgREST returns rows for an anonymous
SELECTwhen RLS is off or when a policy permits it. Either case is a finding. - The anonymous role can list tables. A
GET /rest/v1/with the anon key returns the OpenAPI schema for every table that theanonrole has any privilege on. AI-generated apps frequently grantUSAGEon the schema andSELECTon every table, which exposes the full schema map even when RLS denies the actual reads. - The anonymous role can insert. A probing
POSTwith a guess at the column shape will succeed if RLS doesn't have anINSERTpolicy denying it โ even ifSELECTis locked down. - The service-role key is in the browser bundle. Adjacent to RLS: if a scanner finds
SUPABASE_SERVICE_ROLE_KEYor any JWT withrole: service_rolein 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 pg_policies table, your migration files, or the exact predicate of any policy. It infers from black-box behaviour, which means it will sometimes report a finding that turns out to be intentional public data (a marketing newsletter table, a public product catalog). The FixVibe report flags these as medium confidence 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 ALTER TABLE ... ENABLE ROW LEVEL SECURITY. PostgREST happily serves the entire table to anyone with the anon key. Fix: ALTER TABLE public.[name] ENABLE ROW LEVEL SECURITY; ALTER TABLE public.[name] FORCE ROW LEVEL SECURITY;. 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 deny, so authenticated users see nothing โ and the developer adds USING (true) to make the app work, which permits everyone to read everything. Fix: write a policy that scopes by auth.uid(): CREATE POLICY "select_own" ON public.[name] FOR SELECT USING (auth.uid() = user_id); 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. FOR SELECT protects reads only; an anonymous client can still INSERT if no policy denies it. Fix: author a policy per command, or use FOR ALL with explicit USING and WITH CHECK clauses.
How the FixVibe Supabase RLS scanner works
The baas.supabase-rls check runs in three stages, each with explicit confidence levels:
- Stage 1 โ fingerprint. 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.
- Stage 2 โ schema discovery. A single
GET /rest/v1/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. - Stage 3 โ read and write probes. For each discovered table, the scanner issues one anonymous
SELECTwithlimit=1. 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:
- Audit every table. Run
SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';in the Supabase SQL editor. Any row withrowsecurity = falseis a problem. - Enable RLS on every public table. Default to
ENABLE ROW LEVEL SECURITYandFORCE ROW LEVEL SECURITYon every table created โ make it a migration template. - Author policies command-by-command. Don't use
FOR ALL USING (true). Write explicit policies for SELECT, INSERT, UPDATE, DELETE โ each one scoped toauth.uid()or an org-id column fromauth.jwt(). - Verify with a second account. Sign up as a different user, attempt to read another user's records via the REST API directly. If the response is
200, the policy is broken. - Re-scan. After applying the fix, re-run a FixVibe scan against the same URL. The
baas.supabase-rlsfinding 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 /rest/v1/ path, and report on the HTML pages they do understand. Snyk and Semgrep 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 SELECT ... limit=1 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 503 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 anon JWTs and the newer sb_publishable_* 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 baas.supabase-rls check is enabled on every plan including the free tier. For a deeper read on what else can leak from a Supabase project, see Supabase service role key exposed in JavaScript and Supabase storage bucket security checklist. For the umbrella view across all BaaS providers, read BaaS misconfiguration scanner.
