Supabase is the most popular database choice for vibe-coded apps. Lovable, Bolt, and Cursor all scaffold Supabase integrations out of the box.
There's one problem: they don't enable Row Level Security by default. And without RLS, your app has no meaningful database security.
This post explains what RLS is, why it matters, and how to set it up in 10 minutes — with prompts you can paste directly into Lovable, Bolt, or Cursor.
What is Row Level Security?
Supabase is built on PostgreSQL. PostgreSQL has a feature called Row Level Security that lets you write policies controlling which rows a user can see or modify.
Without RLS:
-- Any authenticated user runs this and gets EVERYONE'S data
SELECT * FROM users;
-- Returns: all 10,000 users
With RLS + a policy:
-- Same query, same user
SELECT * FROM users;
-- Returns: only the row where id = auth.uid()
The difference is enforced at the database level — it doesn't matter how clever or careless the application code is.
Why does Supabase expose the anon key?
Supabase gives you two keys:
- anon key — safe to expose in the frontend, because RLS controls what it can do
- service role key — bypasses RLS entirely, must never be in frontend code
The design intent is that the anon key is harmless if you've set up RLS. The key is visible. An attacker can see it. But when they try to use it to query your database, they can only access rows your policies allow.
The problem: AI tools put the anon key in NEXT_PUBLIC_* env vars (correct) but don't enable RLS (catastrophic omission). So the key is exposed, and an attacker can use it to read everything.
The attack takes 2 minutes
Here's what happens with no RLS:
- Attacker visits your app
- Opens browser DevTools → Network tab → finds a Supabase request
- Copies the URL and anon key from the request headers
- Runs this in any JavaScript environment:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://yourproject.supabase.co',
'your-anon-key-from-the-browser'
)
// Get all users
const { data } = await supabase.from('users').select('*')
console.log(data) // Every user record
That's it. No hacking required. Just reading what you've left open.
Which tables need RLS
Every table that contains:
- User data (profiles, preferences, settings)
- Private content (notes, documents, drafts)
- Financial data (orders, invoices, payment history)
- Health data (anything sensitive)
Tables that might be OK without RLS:
- Public product catalog (read-only, same for all users)
- Global config (read-only, non-sensitive)
- Public blog posts
Even for public tables, it's better to enable RLS and add an explicit FOR SELECT USING (true) policy. That way nothing is accidentally open.
How to enable RLS (the fast way)
Via Supabase Dashboard
- Go to Table Editor in your Supabase project
- Select each table
- Click the RLS tab → Enable RLS
- Add policies for each operation (SELECT, INSERT, UPDATE, DELETE)
Via SQL
-- Enable RLS on all your tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- Standard user-owns-their-data policies
CREATE POLICY "users_select_own_profile" ON profiles
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "users_update_own_profile" ON profiles
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "users_insert_own_profile" ON profiles
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Orders: users see their own orders
CREATE POLICY "users_select_own_orders" ON orders
FOR SELECT USING (auth.uid() = user_id);
-- Documents: users see their own documents
CREATE POLICY "users_select_own_docs" ON documents
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "users_insert_own_docs" ON documents
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "users_update_own_docs" ON documents
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "users_delete_own_docs" ON documents
FOR DELETE USING (auth.uid() = user_id);
Prompt to paste into your AI tool
If you're using Lovable, Bolt, or Cursor:
"VibeScan found that Row Level Security is not enabled on my Supabase tables. Please enable RLS on every table in the project and add appropriate policies. For tables that contain user-owned data (profiles, orders, documents, etc.), add SELECT, INSERT, UPDATE, and DELETE policies that use
auth.uid() = user_idto restrict access to the owner only. For any public tables like products or pricing, add a SELECT policy withUSING (true)to explicitly allow public reads. Generate the SQL migration for all of this."
Testing your RLS policies
After you add policies:
- Create two test accounts (user A and user B)
- Log in as user A and create some data
- Log in as user B and try to fetch user A's data via the Supabase client
- You should get an empty result, not user A's data
If you're still seeing other users' data, the policy syntax is wrong or it wasn't applied to the right table.
The service role key — one more thing
One more pattern we see in AI-generated code:
// In a Next.js component (client-side) 🚨
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY // THIS IS WRONG
)
The service role key bypasses RLS. If it's in a NEXT_PUBLIC_* variable, it's in your JavaScript bundle, and it's effectively public. Anyone who finds it can do anything to your database.
The service role key should only ever appear in:
- Server-side API routes (
/api/*) - Edge functions
- Background jobs that run on the server
Never in any component, hook, or client-side utility.
Setting up RLS correctly takes about 10 minutes. Without it, you're essentially running your database in public mode. VibeScan can tell you in 60 seconds whether your app shows signs of missing RLS — scan your app free.