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.
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
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.
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 |
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).
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.
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.
COMMON QUESTIONS
AUDIT EVERY TABLE + AUTH FLOW
The VibeEval agent extends this into auth bypass testing and cross-user data probing.