The Supabase RLS Mistake That Could Expose Your Users' Data
Row Level Security is the single most important security feature in Supabase. It is also the most commonly misconfigured feature in AI-built apps. We have seen three patterns that show up over and over.
Mistake 1: USING(true) on "Service Role" Policies
This is the most dangerous RLS mistake we see, and it comes from a reasonable-sounding idea: "I want my service role to have full access, so I will create a policy with USING(true)."
The dangerous policy:
-- This looks like it's only for the service role... but it's not
CREATE POLICY "Service role full access" ON profiles
FOR ALL
USING (true);
The problem: PostgreSQL policies are additive. A policy with USING(true) grants access to every role, not just the service role. That includes the anon role, which is what your public Supabase client uses.
In other words: this policy gives every visitor to your site full read and write access to the profiles table. It is functionally equivalent to having no RLS at all.
The fix — restrict to authenticated users with ownership check:
-- Drop the dangerous policy
DROP POLICY "Service role full access" ON profiles;
-- Proper policy: only the row owner can access their data
CREATE POLICY "Users access own data" ON profiles
FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
If you genuinely need service-role-only access (for admin operations, migrations, etc.), do not use RLS policies at all. Use the service role key server-side, which bypasses RLS entirely. That is the intended pattern.
Mistake 2: UPDATE Without WITH CHECK
This one is subtle and AI tools almost never get it right. You create a policy that lets users update their own rows. It works. But you forget the WITH CHECK clause.
Missing WITH CHECK:
-- Users can update their own profile
CREATE POLICY "Users update own profile" ON profiles
FOR UPDATE
USING (auth.uid() = user_id);
-- No WITH CHECK clause!
The problem: USING controls which rows you can see for the update. WITH CHECK controls what the row looks like after the update. Without WITH CHECK, a user can update their own row to change any column — including columns like role, plan, or org_id.
Privilege escalation attack:
// Attacker's browser console
const { error } = await supabase
.from('profiles')
.update({ role: 'admin', plan: 'enterprise' })
.eq('user_id', myUserId)
// Without WITH CHECK, this succeeds.
// The user is now an admin on the enterprise plan.
The fix — add WITH CHECK to constrain updates:
CREATE POLICY "Users update own profile" ON profiles
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (
auth.uid() = user_id
AND role = (SELECT role FROM profiles WHERE user_id = auth.uid())
AND plan = (SELECT plan FROM profiles WHERE user_id = auth.uid())
);
The WITH CHECK clause ensures that even if a user can update their own row, they cannot change the role or plan columns. The values must stay the same as what is already in the database.
Mistake 3: RLS Enabled but No Policies
Some AI tools have learned that RLS should be enabled. So they run ALTER TABLE ... ENABLE ROW LEVEL SECURITY. But they do not create any policies.
RLS enabled, no policies:
-- AI generated this
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ...and nothing else.
-- No SELECT policy. No INSERT policy. No UPDATE policy.
The problem: With RLS enabled and no policies, the anon and authenticated roles cannot read or write anything. Your app appears broken. So what does the developer do? They either disable RLS entirely or add USING(true) to "fix" it. Both make things worse.
The fix: After enabling RLS, you need at minimum a SELECT policy for authenticated users:
Minimum viable RLS:
-- Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Users can see their own orders
CREATE POLICY "Users see own orders" ON orders
FOR SELECT
USING (auth.uid() = user_id);
-- Users can create orders (and they must own them)
CREATE POLICY "Users create own orders" ON orders
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own orders
CREATE POLICY "Users update own orders" ON orders
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
How to Test Your RLS
You do not need a security scanner to check if your RLS is broken. Open your browser's DevTools console on your live site and run this:
Quick RLS test (run in browser console):
// Your anon key is already in the client bundle, so this is what attackers see
const { createClient } = await import('https://esm.sh/@supabase/supabase-js')
const supabase = createClient(
'https://YOUR_PROJECT.supabase.co',
'YOUR_ANON_KEY' // This is public
)
// Try to read all data from a table
const { data, error } = await supabase.from('profiles').select('*')
console.log('Profiles accessible:', data?.length, 'rows')
// If this returns data from other users, your RLS is broken
If that query returns rows belonging to other users, your RLS is not protecting you. If it returns all rows in the table, you effectively have no security at all.
Go Deeper
These three mistakes cover the most common RLS failures, but there are more: policies that check the wrong column, policies that use current_user instead of auth.uid(), policies that grant DELETE to anyone authenticated.
VibeArmor's deep scan tests your actual Supabase instance by attempting cross-user data access with your anon key. It does not guess — it proves whether the data is accessible.
Scan your app free
Paste a URL, get a letter grade and Cursor-ready fixes in 3 minutes. No signup required.
Start Free Scan