FixVibe

// 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 looks at your Supabase project the way an anonymous visitor would. It uses only the publishable anon key β€” the same key your browser uses β€” and never authenticates as a user or touches service-role privileges. Anything it can see, an unauthenticated attacker on the internet can see.

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 SELECT when 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 the anon role has any privilege on. AI-generated apps frequently grant USAGE on the schema and SELECT on every table, which exposes the full schema map even when RLS denies the actual reads.
  • Read access does not establish write safety. A passive scan cannot prove that INSERT, UPDATE, or DELETE policies protect every user and tenant. Review write policies and test them with isolated fixtures under the intended roles.
  • The service-role key is in the browser bundle. Adjacent to RLS: if a scanner finds SUPABASE_SERVICE_ROLE_KEY or any JWT with role: service_role 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 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 come up again and again. 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;, then add a policy per command. Enabling RLS with no policies denies all access through the API; the policies decide who gets in.

sql
ALTER TABLE public.[name] ENABLE 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.

sql
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 USING (user_id = user_id) β€” which is always true β€” instead of USING (auth.uid() = user_id). Type-checks pass; the policy permits every row. Fix: always compare a column to a function call (auth.uid(), auth.jwt()->>'org_id', etc.), never to itself or to a constant.

Shape 4: Overly permissive write policies

With RLS enabled, a role with no applicable policy is denied by default. A SELECT policy does not itself allow INSERT. Write exposure can arise from a permissive applicable INSERT or ALL policy, disabled RLS, or a role that bypasses RLS. Fix: inspect table privileges and every applicable policy, use ownership-scoped WITH CHECK conditions, and test writes with separate user identities.

How the FixVibe Supabase RLS scanner works

FixVibe's Supabase check works from the deployed app, in three steps:

  1. Find the project. FixVibe loads the deployed app and reads the Supabase project URL and public key from the same configuration your browser receives. No guessing, no brute force.
  2. See what is exposed. FixVibe checks which tables the anonymous role can see, without reading row data at this step.
  3. Stage 3 β€” inspect anonymous read access. The passive check reports observable access to discovered resources. It does not create, update, or delete database rows, and it cannot establish that all write policies or cross-user boundaries are correct.

Each finding ships with the exact request URL, response status, response shape (header-only), and the table name. FixVibe now labels whether the fix belongs in code/config, a provider console, DNS, secret rotation, or manual review so the next step matches who can actually apply it.

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:

  1. Audit every table. Run SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; in the Supabase SQL editor. Any row with rowsecurity = false is a problem.
  2. Enable RLS on every public table. Default to ENABLE ROW LEVEL SECURITY on every table created β€” make it a migration template.
  3. Author policies command-by-command. Don't use FOR ALL USING (true). Write explicit policies for SELECT, INSERT, UPDATE, DELETE β€” each one scoped to auth.uid() or an org-id column from auth.jwt().
  4. 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.
  5. Re-scan. After applying the fix, re-run a FixVibe scan against the same URL. The Supabase RLS finding should clear.
sql
-- 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?

The passive RLS check is read-only. It examines observable anonymous read access and reports limited evidence. It does not perform database write operations or prove that every user and tenant is correctly isolated.

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 (sb_publishable_*)?

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 Supabase RLS check runs 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.

// scan your baas surface

Find the open table before someone else does.

Drop in a production URL. FixVibe enumerates the BaaS providers your app talks to, fingerprints their public endpoints, and reports what an unauthenticated client can read or write. Free, no install, no card.

  • Free tier β€” 3 scans / month, no signup card.
  • Passive BaaS fingerprinting β€” no domain verification needed.
  • Supabase, Firebase, Clerk, Auth0, Appwrite, and more.
  • Coding-agent prompts where code/config applies, plus provider-console steps for hosted BaaS fixes.
Run a free BaaS scan β†’

no signup required

Supabase RLS scanner: find tables with missing or broken row-level security Β· FixVibe