Supabase RLS for Vibe-Coded Apps: The Security You're Missing

Written by the Rafter Team

Row-Level Security is Supabase's primary access control mechanism, and most vibe-coded apps ship without it. When RLS is disabled, your Supabase anon key—embedded in every client-side bundle—grants unrestricted read and write access to every row in every table. A 2025 investigation found 170+ Lovable-built apps with fully exposed databases because developers never enabled RLS. One leaked database contained 13,000 user records. This isn't a theoretical risk. It's the default state of most AI-generated Supabase apps.
If you built your app with an AI coding assistant and use Supabase, check your RLS right now. Run this query in the Supabase SQL Editor: SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT rowsecurity; Any table in the results is completely exposed.
Introduction
Supabase is the default backend for vibe-coded applications. Lovable, Bolt.new, Base44, and dozens of other platforms scaffold Supabase projects as their primary data layer. The combination is powerful—you get a PostgreSQL database, authentication, real-time subscriptions, and edge functions out of the box.
But Supabase's security model depends entirely on Row-Level Security, and AI coding assistants almost never configure it correctly. This post covers:
- How RLS works and why it's critical for client-facing apps
- The specific ways AI assistants misconfigure RLS
- Step-by-step patterns for every common table type
- How to audit your existing RLS policies
- Advanced patterns for multi-tenant and role-based access
How Supabase Security Actually Works
Supabase exposes your PostgreSQL database directly to the client through its REST API (PostgREST) and real-time API. Every request from the browser hits your database—there's no application server sitting in between to enforce access control.
This architecture means your database must enforce its own security. That's what RLS does.
The Two Keys
Supabase provides two API keys:
| Key | Purpose | RLS Behavior |
|---|---|---|
anon key | Client-side requests, unauthenticated users | Respects RLS policies |
service_role key | Server-side admin operations | Bypasses all RLS |
The anon key is public. It ships in your JavaScript bundle, it's visible in network requests, and anyone can extract it. This is by design—the anon key is safe only if RLS is enabled and correctly configured.
The service_role key is a superuser credential. It bypasses every RLS policy. If this key appears in client-side code, your database is fully exposed regardless of your RLS configuration.
// ✗ Vulnerable: service_role key on the client
const supabase = createClient(url, 'eyJ...service_role_key...');
// ✓ Secure: anon key on the client, service_role only on the server
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
What Happens Without RLS
Without RLS, PostgREST grants the anon role full access. An attacker can:
# Read every row in the users table
curl 'https://your-project.supabase.co/rest/v1/users?select=*' \
-H 'apikey: your-anon-key'
# Delete every row in the todos table
curl -X DELETE 'https://your-project.supabase.co/rest/v1/todos?id=gt.0' \
-H 'apikey: your-anon-key'
# Insert arbitrary data
curl -X POST 'https://your-project.supabase.co/rest/v1/admin_settings' \
-H 'apikey: your-anon-key' \
-d '{"key": "admin_email", "value": "attacker@evil.com"}'
No authentication required. No special tools. Just the anon key from your public JavaScript.
How AI Assistants Get RLS Wrong
AI coding assistants fail at RLS in predictable ways. Understanding these patterns helps you catch them during review.
Mistake 1: Never Enabling RLS
The most common failure. The AI creates tables, inserts data, builds the UI—and never runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY. When prompted to "build a task manager with Supabase," the AI focuses on the task manager. RLS isn't part of the prompt, so it's not part of the output.
Mistake 2: Using the Service Role Key Everywhere
When the AI encounters permission errors (because RLS is enabled but no policies exist), it switches to the service_role key. The errors disappear. The app "works." And your database is now accessible to anyone who views your page source.
Mistake 3: Overly Permissive Policies
When the AI does create RLS policies, they're often too broad:
-- ✗ Dangerous: Lets everyone read everything
CREATE POLICY "Allow read access" ON todos
FOR SELECT USING (true);
-- ✗ Dangerous: No ownership check on mutations
CREATE POLICY "Allow all inserts" ON todos
FOR INSERT WITH CHECK (true);
These policies satisfy the "RLS enabled" checkbox without providing actual access control.
Mistake 4: Forgetting RLS on Related Tables
The AI enables RLS on the primary todos table but forgets the todo_tags, todo_comments, or todo_attachments tables. Attackers enumerate data through the unprotected related tables.
Mistake 5: Views That Bypass RLS
Views in PostgreSQL use security definer by default when created by the postgres user. This means the view executes with the permissions of its creator—bypassing RLS entirely.
-- ✗ Dangerous: View bypasses RLS (default behavior)
CREATE VIEW user_profiles AS
SELECT id, name, email FROM users;
-- ✓ Secure: Explicitly use security invoker
CREATE VIEW user_profiles
WITH (security_invoker = true) AS
SELECT id, name, email FROM users;
RLS Patterns for Every Common Table Type
User-Owned Resources (Tasks, Posts, Notes)
The most common pattern. Each row belongs to one user.
-- Enable RLS
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
-- Users can only see their own todos
CREATE POLICY "select_own" ON todos
FOR SELECT USING (auth.uid() = user_id);
-- Users can only insert todos assigned to themselves
CREATE POLICY "insert_own" ON todos
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Users can only update their own todos
CREATE POLICY "update_own" ON todos
FOR UPDATE USING (auth.uid() = user_id);
-- Users can only delete their own todos
CREATE POLICY "delete_own" ON todos
FOR DELETE USING (auth.uid() = user_id);
Shared Resources (Team Documents, Project Files)
Resources accessible to multiple users through team membership.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Users can see documents belonging to their teams
CREATE POLICY "select_team_docs" ON documents
FOR SELECT USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
-- Only team members can insert documents
CREATE POLICY "insert_team_docs" ON documents
FOR INSERT WITH CHECK (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
Public Read, Authenticated Write (Blog Posts, Products)
Content that anyone can read but only authenticated users can create.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Anyone can read published posts
CREATE POLICY "public_read" ON posts
FOR SELECT USING (published = true);
-- Authenticated users can insert their own posts
CREATE POLICY "auth_insert" ON posts
FOR INSERT WITH CHECK (auth.uid() = author_id);
-- Authors can update their own posts
CREATE POLICY "author_update" ON posts
FOR UPDATE USING (auth.uid() = author_id);
Admin-Only Tables (Settings, Config)
Tables that only administrators should access.
ALTER TABLE app_settings ENABLE ROW LEVEL SECURITY;
-- Only admins can read settings
CREATE POLICY "admin_read" ON app_settings
FOR SELECT USING (
auth.uid() IN (
SELECT id FROM users WHERE role = 'admin'
)
);
-- Only admins can modify settings
CREATE POLICY "admin_write" ON app_settings
FOR ALL USING (
auth.uid() IN (
SELECT id FROM users WHERE role = 'admin'
)
);
Auditing Your Existing RLS Configuration
Step 1: Find Tables Without RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY rowsecurity, tablename;
Any row with rowsecurity = false is completely unprotected.
Step 2: Review Existing Policies
SELECT
schemaname,
tablename,
policyname,
permissive,
roles,
cmd,
qual,
with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;
Look for:
qualcontainingtrue—means no restriction- Missing policies for certain operations (SELECT exists but DELETE doesn't)
- Policies that don't reference
auth.uid()
Step 3: Test Policies Manually
Use the Supabase SQL Editor to impersonate a user and verify policies work:
-- Set the role to a specific user
SET request.jwt.claims = '{"sub": "user-uuid-here"}';
SET role = 'authenticated';
-- Try to read another user's data
SELECT * FROM todos WHERE user_id != 'user-uuid-here';
-- Should return empty results if RLS is correct
Step 4: Scan with Rafter
Rafter automatically detects tables without RLS, overly permissive policies, and service role key exposure. Connect your repo and get findings in seconds.
Advanced RLS Patterns
Performance: Avoiding Subquery Overhead
RLS policies with subqueries run on every row. For large tables, use a security definer function to cache the lookup:
-- Create a function that caches team membership
CREATE OR REPLACE FUNCTION auth.user_team_ids()
RETURNS SETOF UUID
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT team_id FROM team_members WHERE user_id = auth.uid()
$$;
-- Use the function in RLS policies
CREATE POLICY "select_team_docs" ON documents
FOR SELECT USING (team_id IN (SELECT auth.user_team_ids()));
Multi-Tenant Isolation
For SaaS apps where data must be strictly isolated between organizations:
-- Add org_id to every table
ALTER TABLE todos ADD COLUMN org_id UUID REFERENCES organizations(id);
-- RLS policy enforces org isolation
CREATE POLICY "org_isolation" ON todos
FOR ALL USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
RLS on Storage Buckets
Don't forget Supabase Storage. File access follows the same RLS model:
-- Restrict file access to authenticated users in the correct org
CREATE POLICY "org_files" ON storage.objects
FOR SELECT USING (
bucket_id = 'org-files' AND
(storage.foldername(name))[1] IN (
SELECT id::text FROM organizations
WHERE id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid())
)
);
Conclusion
RLS is the single most impactful security control for vibe-coded Supabase apps. Without it, your database is a public API. With it, every query is scoped to the requesting user's permissions.
Your next steps:
- Run the audit query above—find every table without RLS in your project right now
- Enable RLS on all tables—even tables you think are "internal only"
- Add per-user policies—start with the user-owned resource pattern and expand from there
- Verify the
service_rolekey never appears in client code—search your codebase for it - Scan with Rafter—automated scanning catches RLS gaps and policy misconfigurations that manual review misses
Related Resources
- Vibe Coding Security: The Complete Guide
- Vibe Coding Is Great — Until It Isn't: Why Security Matters
- Next.js Security Checklist for AI-Generated Projects
- How to Thoroughly Test Your Vibe-Coded App
- Security Audit: Lovable
- Security Audit: Base44
- Automated Security Scanning for Modern Applications
- API Keys Explained: Secure Usage for Developers