Quick Answer
If you built a Supabase app with Bolt, Lovable, or Cursor and your tables have Row Level Security (RLS) turned off, anyone holding your public API key can read, modify, and delete every user's data. Supabase turns RLS on by default only for tables created in its dashboard. Tables created with SQL, which is how migrations and most AI-generated code create them, start with RLS off.
What Row Level Security Is and Why It Matters
Row Level Security (RLS) is PostgreSQL's built-in access control system that restricts which rows a user can access in each table. In Supabase, the client library talks to your database through Supabase's auto-generated Data API using a public API key. Without RLS, anyone holding that key, signed in or not, can query any row in any table the API exposes.
Supabase's documentation warns that a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Yet in practice, AI coding tools generate Supabase client code without creating the corresponding RLS policies. The app works - every query returns data - but there is zero data isolation between users.
The OWASP Top 10 2025 keeps broken access control as the number one web application security risk: 100% of the applications tested were found to have some form of it, and it had more occurrences in the contributed data than any other category. Missing RLS in Supabase is the textbook example of this vulnerability.
The Default State: No Protection
Supabase's docs say tables created through the dashboard have RLS enabled by default, but tables created in the SQL Editor or with other tools do not. On projects that grant the anon and authenticated roles access to new public tables (the default on existing projects, which Supabase is changing so that exposure becomes opt-in), a table without RLS lets the anon key and any signed-in user perform any operation on any row.
// ❌ BAD - Without RLS, this returns ALL users' messages
const { data } = await supabase
.from('messages')
.select('*');
// Returns messages from every user, not just the current user
// Even worse - anyone can delete anyone's data
await supabase
.from('messages')
.delete()
.eq('id', anyMessageId); // No ownership check
-- ✅ GOOD - Enable RLS and create proper policies
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Users can only see their own messages
CREATE POLICY "Users read own messages" ON messages
FOR SELECT USING (user_id = auth.uid());
-- Users can only insert messages as themselves
CREATE POLICY "Users insert own messages" ON messages
FOR INSERT WITH CHECK (user_id = auth.uid());
-- Users can only delete their own messages
CREATE POLICY "Users delete own messages" ON messages
FOR DELETE USING (user_id = auth.uid());
How AI Tools Get Supabase Auth Wrong
AI coding tools make three consistent Supabase auth mistakes:
- Client-side filtering instead of RLS: The code adds
.eq('user_id', userId)to queries, creating the illusion of data isolation. But an attacker can remove this filter by calling the Supabase API directly. - Using the service role key on the client: The service role key bypasses RLS entirely. AI tools sometimes use it in client-side code, giving every user full database admin access.
- Overly broad policies for specific operations: Even when RLS is enabled, an UPDATE or DELETE policy written as
USING (true)lets users read only their own data but modify anyone's. A missing policy is the safer failure: Postgres denies that operation by default.
// ❌ BAD - Client-side filtering is NOT security
const { data } = await supabase
.from('profiles')
.select('*')
.eq('user_id', currentUser.id); // Attacker can just change this ID
// ❌ VERY BAD - Service role key in client code
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY // Bypasses ALL RLS
);
Testing Your RLS Policies
You can verify your RLS setup directly in the Supabase SQL editor:
-- Check which tables have RLS enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
-- Test as a specific user (replace with a real user ID).
-- The SQL editor runs as the table owner, which bypasses RLS, so switch role first.
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '{"sub": "user-uuid-here"}';
SELECT * FROM messages; -- Should only return this user's messages
ROLLBACK;
| Table Type | RLS Policy Pattern | Example |
|---|---|---|
| User-owned data | user_id = auth.uid() |
profiles, settings, messages |
| Team-shared data | team_id IN (user's teams) |
projects, documents, tasks |
| Public read, owner write | SELECT: true, UPDATE/DELETE: user_id = auth.uid() |
blog posts, public profiles |
| Admin-only | auth.jwt() -> 'app_metadata' ->> 'role' = 'admin' |
admin settings, user management |
How to Fix Your Supabase App
- Audit every table: Open Advisors > Security in the Supabase dashboard. Its "RLS Disabled in Public" check flags every
publictable without RLS as an error. Fix those immediately. - Enable RLS on all tables:
ALTER TABLE tablename ENABLE ROW LEVEL SECURITY; - Create policies for each operation: SELECT, INSERT, UPDATE, DELETE. Do not assume one policy covers all operations.
- Remove the service role key from client code: Search your frontend code for
service_roleor the service key value. Move it to server-side only. - Scan your codebase: Tools like VibeDoctor's Vibe Check flag hardcoded keys and tokens (including a service role key pasted into source code) and unprotected Express, Fastify, and NestJS API routes. It does not read your database's RLS settings, so step 1 still matters. Free to sign up.
If your app also runs Supabase Edge Functions, check them against our guide to Edge Function security mistakes, starting with where the service role key is used.
FAQ
Is the Supabase anon key safe to expose?
Yes - the anon key is designed to be public. It only has the permissions you define through RLS policies. Without RLS, the anon key has full read/write access to all tables. With proper RLS, the anon key can only access what your policies allow. The service role key bypasses all RLS and must never be exposed on the client.
Does enabling RLS break my existing queries?
Yes, intentionally. When you enable RLS on a table with no policies, all queries will return zero rows (default deny). This is the correct behavior - it forces you to explicitly define who can access what. Add policies for each operation your app needs before enabling RLS in production.
Can I test RLS policies without affecting production?
Yes. Supabase provides a local development environment via supabase start that mirrors your production setup. Write and test your RLS policies locally, verify they work with your app, then apply them to production using migrations.
Do RLS policies affect performance?
Minimal impact for well-written policies. Supabase recommends indexing every column your policies filter on, such as user_id, and writing the check as (select auth.uid()) = user_id so Postgres evaluates auth.uid() once per statement instead of once per row. Complex policies with subqueries may need more optimization, but for most apps, the performance impact is small.
Does Lovable set up RLS when it generates Supabase apps?
Do not rely on it. Lovable has integrated Supabase's Security Advisor to flag issues such as missing RLS before you publish, but Lovable itself says incorrect database security setups sometimes slip through. Always verify your RLS policies regardless of which tool generated the code.