SUPABASE RLS CHECKER

Missing RLS is the #1 vulnerability in Lovable and Bolt apps. This tool tests every public table through the anon key — exactly like an attacker would.

TEST YOUR SUPABASE TABLES NOW

Enter your deployed app URL — we discover the Supabase project from your bundle and probe every public table with the anon key.

Why This Is the #1 Lovable/Bolt Vulnerability

Lovable and Bolt both default to Supabase. Supabase tables are created with RLS disabled by default. The AI scaffolds your app, data flows, everything works in preview — and in production, every row in users, orders, messages, documents is readable by anyone with a browser console.

We see this in roughly 85% of scanned Lovable apps.

The Supabase anon key is designed to ship to the browser — that’s how the client SDK reaches the database. The defence layer is RLS. When RLS is off, the anon key becomes a full read/write key for your entire schema, distributed to every visitor of your site.

That is not a theoretical API design quirk. It is the modal critical finding across vibe-coded SaaS: open tables behind a public PostgREST endpoint, with a key that is meant to be public only because policies constrain it.

What the Checker Does

  1. Discovers the Supabase project URL and anon key from your frontend bundle
  2. Enumerates public tables via the PostgREST ?select=* introspection on the OpenAPI spec at /rest/v1/
  3. Probes each table with an anon read, checking for:
    • Unrestricted read (critical)
    • Unrestricted write/delete (critical)
    • Missing user-scoped policy (high)
    • Over-permissive policy (medium)
    • Storage bucket access via storage/v1/object/public
  4. Reports each finding with exact SQL to fix and a verification query

The checker deliberately uses the same materials an attacker already has: your HTML/JS and the anon JWT. It does not need your dashboard login. That is the point — if we can read it, so can they.

Common Finding Types

TABLE FULLY PUBLIC

Anon key reads every row. Critical. Most common finding.

WRITE PERMITTED

Anyone can insert/update/delete. Catastrophic if not caught.

USER-SCOPE MISSING

RLS enabled but policy doesn't filter by auth.uid().

OVER-SHARED COLUMNS

RLS works but selects return email/phone/PII to every requester.

RPC WIDE OPEN

Postgres functions exposed via /rest/v1/rpc/* with no EXECUTE restriction.

STORAGE BUCKET PUBLIC

Public bucket with sensitive uploads (invoices, IDs, avatars containing EXIF).

VIEWS BYPASS RLS

Views defined as SECURITY DEFINER run as the owner — RLS on the underlying tables is silently bypassed.

SERVICE-ROLE KEY LEAK

Cross-check: if the bundle contains the service_role key (not just the anon key), every other RLS finding is moot.

How attackers find your Supabase project

The recon is one curl away:

  1. View source on your site, search supabase.co — the URL https://<projectRef>.supabase.co is right there along with the anon JWT.
  2. Decode the JWT at jwt.io. The ref claim confirms the project, role: anon confirms it’s the public key.
  3. Hit https://<projectRef>.supabase.co/rest/v1/?apikey=<anonKey> to get the OpenAPI spec listing every table, view, and function in your public schema.
  4. For each table, GET /rest/v1/<table>?select=*&limit=1000 with the anon key. If RLS is off, you get rows. Pagination via Range headers will return up to 10,000 rows per request.
  5. For writes, POST /rest/v1/<table> with a JSON body. If RLS is off (or only SELECT policies exist), inserts succeed.
  6. Storage: GET /storage/v1/bucket lists buckets. Each public bucket exposes /storage/v1/object/public/<bucket>/<path>.

The whole chain runs in seconds with no rate limit on a misconfigured project. Most exfiltrated Supabase databases are dumped this way — not via SQL injection, just via the published API.

Policy Starter Pack

Basic secure patterns (paste into Supabase SQL editor):

-- Enable RLS on a table (RLS is OFF by default — you must opt in)
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Force RLS even for table owners — protects against future role drift
ALTER TABLE posts FORCE ROW LEVEL SECURITY;

-- SELECT: users read their own rows
CREATE POLICY "posts_select_own" ON posts
  FOR SELECT
  TO authenticated
  USING (auth.uid() = user_id);

-- INSERT: users can only create rows they own
CREATE POLICY "posts_insert_own" ON posts
  FOR INSERT
  TO authenticated
  WITH CHECK (auth.uid() = user_id);

-- UPDATE: users can only modify rows they own (and can't change ownership)
CREATE POLICY "posts_update_own" ON posts
  FOR UPDATE
  TO authenticated
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

-- DELETE: explicit, separate from update
CREATE POLICY "posts_delete_own" ON posts
  FOR DELETE
  TO authenticated
  USING (auth.uid() = user_id);

Deploy, rescan, repeat until the report shows zero criticals.

Public read, private write (content sites)

CREATE POLICY "posts_public_read" ON posts
  FOR SELECT TO anon, authenticated
  USING (published = true);

CREATE POLICY "posts_author_write" ON posts
  FOR ALL TO authenticated
  USING (auth.uid() = author_id)
  WITH CHECK (auth.uid() = author_id);

Never use public read on tables that mix public fields with private columns (email next to bio). Split tables or use column-level views carefully.

Common policy pitfalls

Mistake What it looks like Fix
RLS enabled, no policies ENABLE ROW LEVEL SECURITY without any CREATE POLICY — table is fully blocked, app breaks, dev “fixes” by disabling RLS again Add at least one SELECT policy with auth.uid() filter
USING (true) Policy exists but condition is always true Replace true with the user-scope filter
FOR ALL over-grants Single policy covers SELECT/INSERT/UPDATE/DELETE — easy to mis-scope Split into per-action policies
WITH CHECK missing on UPDATE User can update their row to set user_id to someone else’s Always pair USING with a matching WITH CHECK on UPDATE
Anonymous (anon) policies TO anon accidentally added — defeats the purpose Use TO authenticated unless you genuinely want public reads
Service role used in client Frontend uses service_role key “just to ship” — bypasses RLS entirely Rotate immediately, switch client to anon key
SECURITY DEFINER views View runs as the owner, ignoring caller’s RLS context Use SECURITY INVOKER (Postgres 15+) or rewrite as a function with row-filter logic
Policy on wrong column Filters id instead of owner_id Match the ownership model your app actually uses

Verify a fix from the command line

After deploying a policy change, replay what the scanner does:

# Should return [] for a properly RLS'd table when called with anon key
curl "https://<ref>.supabase.co/rest/v1/posts?select=*&limit=1" \
  -H "apikey: <anon_key>" \
  -H "Authorization: Bearer <anon_key>"

# Should return your row(s) only when called with a real user JWT
curl "https://<ref>.supabase.co/rest/v1/posts?select=*&limit=1" \
  -H "apikey: <anon_key>" \
  -H "Authorization: Bearer <user_jwt>"

If the first call still returns rows, the policy isn’t filtering. The most common cause: a leftover USING (true) from before, or RLS was never enabled on this specific table (check pg_class.relrowsecurity).

select tablename, rowsecurity
from pg_tables
where schemaname = 'public';

What this scanner does NOT flag

  • Tables in non-public schemas. PostgREST exposes only the schemas you configure. If you’ve moved sensitive data to a non-exposed schema, the scanner can’t see it (which is the desired outcome, but worth noting).
  • Custom JWT claims used by your policies. If a policy uses auth.jwt()->>'org_id' and the scanner doesn’t have an org-scoped JWT, it can’t determine whether cross-org isolation works. We mark these Pass-with-caveat and recommend authenticated re-testing.
  • Realtime subscription channels. Realtime has its own RLS evaluation — the scanner tests REST, not WebSocket subscriptions. If your channel filters are wrong, an attacker can subscribe to other users’ inserts. Test channels manually with the JS client.
  • Edge Function authorization. Edge Functions run with the service role by default and bypass RLS unless you explicitly thread through the user’s JWT. Out of scope for this checker — covered by the full Vibe Code Scanner.
  • Database backup files. If pg_dump snapshots are accessible via Storage, no amount of RLS helps.

Multi-tenant and org-scoped policies

SaaS apps often need more than auth.uid() = user_id:

-- Example: membership table drives access
create policy "org members read projects"
  on projects for select to authenticated
  using (
    exists (
      select 1 from org_members m
      where m.org_id = projects.org_id
        and m.user_id = auth.uid()
    )
  );

Pitfalls: recursive policies that call tables without RLS; policies that only check org on SELECT; clients that can insert themselves into org_members. Test with users in org A and org B, not just “logged in vs anon.”

Hardening membership:

-- Members cannot self-insert into arbitrary orgs
create policy "no self-join without invite"
  on org_members for insert to authenticated
  with check (false); -- inserts only via service role / edge after invite accept

Migration workflow that stays secure

  1. Create table in a migration.
  2. Same migration: ENABLE ROW LEVEL SECURITY + policies (or FORCE RLS).
  3. Deploy migration before frontend code that depends on open access.
  4. Run this checker against staging URL.
  5. Promote to production; scan production URL.
  6. Never leave a “temporary” USING (true) policy in the migration history without a follow-up PR that removes it.

AI tools often emit step 1 without step 2. Make step 2 a required review comment on every schema PR.

-- CI assertion sketch
do $$
begin
  if exists (
    select 1 from pg_tables
    where schemaname = 'public' and rowsecurity = false
  ) then
    raise exception 'RLS disabled on a public table';
  end if;
end $$;

How to verify after the scanner reports green

Green on anon probes means anonymous access is blocked. Still verify:

  1. Authenticated user A cannot read user B’s rows (manual or deep agent).
  2. service_role is absent from the bundle (Token Leak Checker).
  3. Storage and RPC are included in your manual pass.
  4. New tables added after the scan are re-checked.
  5. Realtime subscriptions respect the same filters as REST.

Lovable / Bolt specific notes

  • New feature prompts create new tables without policies → rescan every prompt that touches data.
  • USING (true) appears when the AI “fixes” a locked table that broke the UI.
  • Edge Functions generated with service role for “simplicity” reintroduce BOLA even when RLS is perfect.
  • Share-password on the Lovable preview does not protect PostgREST — attackers skip the UI.

Reading checker results without theater

“RLS enabled” with a policy using (true) is still open. The checker’s anon probes are the ground truth: if anon select returns rows, users are exposed regardless of dashboard green checks.

Fix order that minimizes downtime

  1. Enable RLS (locks table closed).
  2. Add correct policies in the same migration.
  3. Test app login paths.
  4. Rescan.

Never enable RLS in production without policies unless you want a total outage — and never “fix” outages with using (true).

What a passing grade really means

A clean RLS checker result means anonymous (and optionally authenticated) probes did not read or write data they should not. It does not mean your Edge Functions are safe, your service role is unexposed, or your business logic cannot be abused with a valid user session beyond the cases tested.

Use the checker as the mandatory first gate on every Supabase-backed deploy. Then run dual-user BOLA tests on the app’s primary resources and a full vibe code scan for keys and open routes. Layers catch what single tools miss.

Policy design workshop

For each table, write one sentence: who can select, insert, update, delete, and under what ownership or membership rule. Translate each sentence into SQL policies with both using and with check where needed. If the sentence is vague (‘admins can do everything’), define admin via a membership claim or table, not a client-set boolean.

Generate tables only with migrations that enable RLS in the same file. AI tools that create tables in the dashboard without policies are how production opens up between scans.

alter table public.projects enable row level security;
create policy projects_select_member on public.projects for select using (
  id in (select project_id from public.project_members where user_id = auth.uid())
);
create policy projects_insert_owner on public.projects for insert
  with check (auth.uid() = owner_id);

CI pattern for RLS

Spin a branch database or local Supabase, apply migrations, seed two users and sample rows, run anon and cross-user probes as a script, fail the build on unexpected row counts. Host the same class of checks against the preview URL with this scanner for environments that only exist after deploy.

Store the probe script next to migrations so policy and test change together. When an agent ‘fixes’ a failing test by loosening a policy, the diff is obvious.

Policy testing matrix for multi-tenant apps

Anon probes catch fully open tables. Authenticated cross-user tests catch policies that only check “logged in.” Build a matrix:

Actor Table Expected
anon private rows empty / 401
user A JWT user A rows visible
user A JWT user B rows empty / 404
user A JWT org shared only if member
service_role any bypass (server only — never in browser)
-- CI assertion: no public table lacks RLS
do $$
begin
  if exists (
    select 1 from pg_tables
    where schemaname = 'public' and rowsecurity = false
  ) then
    raise exception 'RLS disabled on a public table';
  end if;
end $$;

Views, RPC, and storage bypasses

  • SECURITY DEFINER views run as owner and ignore caller RLS — prefer SECURITY INVOKER (Postgres 15+) or filter inside the view definition.
  • RPC at /rest/v1/rpc/* needs EXECUTE grants and internal authz; table RLS does not automatically apply inside security-definer functions.
  • Storage policies are separate from table RLS; a public bucket dumps files even when tables are locked.
  • Realtime evaluates RLS on select; wrong channel filters leak inserts across users.
-- Storage: owner-scoped objects (sketch)
create policy "own objects"
  on storage.objects for select to authenticated
  using (auth.uid()::text = (storage.foldername(name))[1]);

AI generator anti-patterns on Supabase

  1. New table without policies after a feature prompt.
  2. USING (true) to “fix” a broken UI.
  3. Client using service_role because anon “failed.”
  4. Edge Function with service-role lookup-by-id and no owner check.
  5. Policies on SELECT only; INSERT/UPDATE open to all authenticated users.
  6. Membership tables that allow self-insert into any org_id.

Membership hardening

create policy "no arbitrary self join"
  on org_members for insert to authenticated
  with check (false); -- inserts only via service role after invite accept

Invite accept handlers must set role from the invite record, never from the client body. Test with users in org A and org B, not only logged-in vs anon.

Verification after a green checker report

  1. Dual-user tests still required for org-scoped data.
  2. Confirm service_role absent from bundle (Token Leak Checker).
  3. Re-check after every migration that adds tables.
  4. Manually probe Storage list and one RPC.
  5. Confirm Realtime subscriptions do not broadcast foreign rows.

Lovable / Bolt regression loop

After each data-touching prompt: enable RLS + policies → deploy → run this checker → dual-user spot check. Never enable RLS alone in production without policies (total outage) and never fix outages with using (true). Share-password on the Lovable preview does not protect PostgREST — attackers skip the UI.

FORCE ROW LEVEL SECURITY

alter table posts force row level security;

FORCE applies RLS even to the table owner, reducing surprise access via powerful DB roles. Enable it on multi-tenant tables. See FAQ above for when it matters.

COMMON QUESTIONS

01
What does 'missing RLS' actually mean?
Supabase exposes your Postgres database over a public REST API. Without Row Level Security (RLS) policies, anyone with your anon key can read, modify, or delete any row in any public table. The anon key is in your frontend — so anyone can use it.
Q&A
02
How do I enable RLS?
In the Supabase dashboard, navigate to the table, enable RLS, then add policies like `auth.uid() = user_id` for reads. Or use SQL: `ALTER TABLE posts ENABLE ROW LEVEL SECURITY;` then `CREATE POLICY ...`. Rescan to verify.
Q&A
03
Does the tool store my data or scan results?
Scan inputs (URL) are stored for support. Data read from your tables during probing is not stored — only counts and table names (which are already public).
Q&A
04
Will enabling RLS break my Lovable app?
If you enable RLS with zero policies, the table blocks everyone — including your app. Add owner-scoped policies in the same change, then retest login flows. Never 'fix' breakage with USING (true).
Q&A
05
Does this replace a full pentest?
No. It focuses on the anon-key / RLS surface that dominates AI-generated Supabase breaches. Combine with auth testing, Edge Function review, and a broader vibe code scan for BOLA and webhooks.
Q&A
06
Can the checker see tables behind the service_role key only?
It uses the public anon key from your bundle — the same as visitors. If only service_role can read a table, that is correct for server-only data; ensure service_role never ships to the client.
Q&A
07
Why is RLS off by default on new tables?
Postgres/Supabase require an explicit opt-in so greenfield schemas are not mysteriously empty. That default is correct for empty prototypes and catastrophic when the same table already holds production rows and PostgREST is public.
Q&A
08
Does FORCE ROW LEVEL SECURITY matter?
Yes for table owners and bypass roles. FORCE applies RLS even to the table owner, reducing surprise access via powerful DB roles. Enable it on multi-tenant tables.
Q&A

AUDIT EVERY TABLE + AUTH FLOW

RLS on one table is not enough. The agent extends this into auth bypass testing and cross-user data probing on the full app.

14-day free trial · No credit card · Cancel anytime

RUN FULL SCAN