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
- Discovers the Supabase project URL and anon key from your frontend bundle
- Enumerates public tables via the PostgREST
?select=*introspection on the OpenAPI spec at/rest/v1/ - 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
- 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:
- View source on your site, search
supabase.co— the URLhttps://<projectRef>.supabase.cois right there along with the anon JWT. - Decode the JWT at jwt.io. The
refclaim confirms the project,role: anonconfirms it’s the public key. - Hit
https://<projectRef>.supabase.co/rest/v1/?apikey=<anonKey>to get the OpenAPI spec listing every table, view, and function in yourpublicschema. - For each table,
GET /rest/v1/<table>?select=*&limit=1000with the anon key. If RLS is off, you get rows. Pagination viaRangeheaders will return up to 10,000 rows per request. - For writes,
POST /rest/v1/<table>with a JSON body. If RLS is off (or onlySELECTpolicies exist), inserts succeed. - Storage:
GET /storage/v1/bucketlists 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-
publicschemas. 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_dumpsnapshots 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
- Create table in a migration.
- Same migration:
ENABLE ROW LEVEL SECURITY+ policies (or FORCE RLS). - Deploy migration before frontend code that depends on open access.
- Run this checker against staging URL.
- Promote to production; scan production URL.
- 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:
- Authenticated user A cannot read user B’s rows (manual or deep agent).
service_roleis absent from the bundle (Token Leak Checker).- Storage and RPC are included in your manual pass.
- New tables added after the scan are re-checked.
- 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
- Enable RLS (locks table closed).
- Add correct policies in the same migration.
- Test app login paths.
- 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
- New table without policies after a feature prompt.
USING (true)to “fix” a broken UI.- Client using
service_rolebecause anon “failed.” - Edge Function with service-role lookup-by-id and no owner check.
- Policies on SELECT only; INSERT/UPDATE open to all authenticated users.
- 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
- Dual-user tests still required for org-scoped data.
- Confirm
service_roleabsent from bundle (Token Leak Checker). - Re-check after every migration that adds tables.
- Manually probe Storage list and one RPC.
- 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.
Related tools and guides
- Lovable Safety Guide — full breakdown of what Lovable apps ship insecure by default.
- Token Leak Checker — confirms whether you accidentally shipped the
service_rolekey. - Free Security Self-Audit — 30-minute manual pass for Supabase + Auth + Edge Functions.
- Lovable Password Protection — pair with RLS to block credential-stuffing into otherwise locked-down rows.
- Supabase RLS Guide — deeper policy patterns.
- Supabase misconfiguration atlas — failure modes in the wild.
COMMON QUESTIONS
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