Row Level Security: The Database Safety Net You Must Enable
Security First — Part 10 of 30
Marcus shipped his freelance project management app on a Friday afternoon. Supabase backend, React frontend, built almost entirely with an AI coding assistant in about three weeks. It looked good. It worked. His first ten clients were happy.
What Marcus didn't know was that every one of those clients could see every other client's projects, invoices, and private notes. Not because someone hacked him. Not because there was a bug in his code. Because he'd never turned on Row Level Security.
Anybody with his app's URL could open the browser console, paste in three lines of JavaScript using his publicly visible API key, and dump his entire database. Name, email, billing status, private project notes — all of it. In plain JSON.
Marcus isn't unusual. He's the rule, not the exception.
In early 2026, a researcher scanned 20,052 indie apps built on vibe-coding platforms and found that 11.04% — more than 1 in 9 — were leaking database credentials or had tables with no Row Level Security at all. Out of 2,960 flagged files, a significant portion contained credentials capable of bypassing RLS entirely. The research was published on Hacker News and prompted a wave of emergency patches across the indie-dev community.
The problem isn't Supabase. The problem is a single checkbox that almost nobody knows to check.
What Row Level Security Actually Does
Imagine your database is a filing cabinet. Without Row Level Security, that cabinet has one lock on the front door — your API key. If someone gets through the door, they can open every drawer and read every file.
Row Level Security adds a lock to each individual file folder. Even if someone gets through the front door, each folder checks: "Are you the person this folder belongs to?" If not, the folder doesn't open.
RLS is a native feature of PostgreSQL — the database engine that powers Supabase. You define policies that describe who can see which rows. The database enforces them automatically, for every query, every time. You can't forget to add a WHERE user_id = current_user clause if the database is doing it for you.
Here's the simplest version of how it works:
-- Step 1: Enable RLS on your table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Step 2: Create a policy that says users can only see their own rows
CREATE POLICY "Users can view own projects"
ON projects
FOR SELECT
USING (auth.uid() = user_id);
That's it. Two SQL statements. Now nobody can read another user's projects — not through the API, not through the Supabase client, not from a malicious browser script. The database itself says no.
Why AI Tools Miss This
If RLS is so important, why do AI coding assistants keep shipping apps without it?
Because AI tools are optimizing for making things work, not making things safe. When you tell an AI to "build a project management app with Supabase," it generates code that connects to your database and returns data. That works. Tests pass. The app loads. The AI is done.
Security policies are invisible during development. There's no error thrown when RLS is disabled. The app behaves identically whether RLS is on or off — until someone deliberately tries to access data they shouldn't.
This is what the security community calls a "misconfiguration" rather than a "bug." And according to OWASP's 2025 Top 10, Security Misconfiguration jumped from #5 to #2 on the list of most critical web application vulnerabilities — now affecting 3% of all tested applications.
The Lovable incident made this concrete. In May 2025, CVE-2025-48757 was published with a CVSS severity score of 8.26–9.3. Researchers found that 170 out of 1,645 Lovable-generated apps — 10.3% — had critical security flaws, with 303 vulnerable Supabase endpoints. The exposed data included full names, emails, home addresses, payment information, personal debt amounts, and developer API keys for services like Google Maps, Gemini, and Stripe.
Lovable was valued at $1.8 billion at the time.
This wasn't a fringe problem. It was systemic.
The Real Risk: Your Supabase Anon Key Is Public
Here's something that confuses almost every vibe coder: your Supabase anon key is meant to be public. It's visible in your frontend JavaScript. That's by design.
Supabase built the system assuming RLS would be enabled. The anon key is supposed to be low-privilege — it can only do what your RLS policies allow it to do. With RLS on, exposing the anon key is fine.
Without RLS, your anon key is a master key.
This is exactly what Wiz Security found when they audited Moltbook, a vibe-coded app, in early 2026:
"When properly configured with Row Level Security (RLS), the public API key is safe to expose — it acts like a project identifier. However, without RLS policies, this key grants full database access to anyone who has it. In Moltbook's implementation, this critical line of defense was missing."
One exposed API key. Entire production database. No hacking required — just knowing the URL.
Setting Up RLS: A Complete Example
Let's build this properly. Say you have a tasks table in a Supabase app. Here's the full RLS setup from scratch.
1. Enable RLS on the table
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
Once you run this, all access is blocked by default. Nothing gets through until you write a policy. That's a feature, not a bug.
2. Let users read their own tasks
CREATE POLICY "Users can view own tasks"
ON tasks
FOR SELECT
USING (auth.uid() = user_id);
auth.uid() is a Supabase function that returns the ID of the currently authenticated user. This policy says: only return rows where user_id matches the logged-in user.
3. Let users create tasks for themselves only
CREATE POLICY "Users can create own tasks"
ON tasks
FOR INSERT
WITH CHECK (auth.uid() = user_id);
Note: INSERT uses WITH CHECK, not USING. This is a common mistake. USING filters what you read. WITH CHECK validates what you write.
4. Let users update and delete their own tasks
CREATE POLICY "Users can update own tasks"
ON tasks
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own tasks"
ON tasks
FOR DELETE
USING (auth.uid() = user_id);
One of the most common RLS mistakes — documented in Supabase's 2025 security research — is locking down SELECT but forgetting UPDATE and DELETE. Attackers who can't read your data can still overwrite or delete it.
5. From your JavaScript/TypeScript frontend
With RLS in place, your Supabase client code doesn't change. The database handles the filtering:
// This looks like it fetches all tasks...
const { data: tasks } = await supabase
.from('tasks')
.select('*');
// ...but with RLS enabled, it only returns the current user's tasks.
// The database filters automatically. No WHERE clause needed in your code.
6. Verify it's working
// Test: can User A see User B's tasks?
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('user_id', 'some-other-users-id');
// With correct RLS: data should be empty [], not a list of their tasks
if (data && data.length > 0) {
console.error('RLS misconfigured — cross-user data is leaking');
}
The Service Role Key: The One Key You Must Hide
Supabase has two main keys: the anon key (public, safe to expose with RLS on) and the service_role key (private, bypasses RLS entirely).
The service_role key is for server-side administrative tasks only. It should never appear in frontend code, .env files committed to Git, or anywhere a user could find it.
From the 2026 vibe-coding exposure study: among thousands of flagged apps, researchers found service_role keys in client-side JavaScript — meaning anyone visiting those sites had a key that bypassed all RLS policies and had full read/write access to the entire database.
Here's the simple rule:
anon key → safe in frontend code (IF RLS is enabled)
service_role key → server only, never in frontend, never in Git
In a Next.js app, that looks like:
// ✅ This is fine in your frontend component
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY // anon key, public
);
// ✅ This is only for server-side API routes or Edge Functions
import { createClient } from '@supabase/supabase-js';
const adminClient = createClient(
process.env.SUPABASE_URL, // NOT prefixed NEXT_PUBLIC_
process.env.SUPABASE_SERVICE_KEY // NOT prefixed NEXT_PUBLIC_
);
Any environment variable prefixed with NEXT_PUBLIC_ is bundled into your JavaScript and visible to anyone who opens DevTools. Never put your service key there.
Audit Your Existing App in Five Minutes
If you have an app running right now, here's how to check your RLS status immediately.
In the Supabase dashboard:
- Go to Table Editor
- Look for tables with a red warning label — as of 2025, Supabase added clear visual warnings for tables with RLS disabled
- Click into any flagged table and enable RLS
With SQL (run this in the Supabase SQL editor):
-- See which of your tables have RLS disabled
SELECT
schemaname,
tablename,
rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY rowsecurity ASC; -- false values appear first
Any row showing rowsecurity = false is a table with the front door wide open.
Enable RLS on everything at once:
-- Nuclear option: enable RLS on all public tables
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN (
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
) LOOP
EXECUTE format(
'ALTER TABLE %I ENABLE ROW LEVEL SECURITY',
r.tablename
);
END LOOP;
END $$;
Warning: This blocks all access until you write policies. Do this in a staging environment first, write your policies, then repeat in production.
What Good RLS Looks Like at Scale
For a multi-tenant SaaS — where you have organizations, teams, and individual users — RLS can handle the full access control hierarchy:
-- Users can see data from their organization
CREATE POLICY "Org members can view org data"
ON documents
FOR SELECT
USING (
org_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid()
)
);
-- Only admins can delete documents
CREATE POLICY "Admins can delete documents"
ON documents
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM org_members
WHERE user_id = auth.uid()
AND org_id = documents.org_id
AND role = 'admin'
)
);
This is policy-as-code. It lives in your database migrations, gets reviewed like any other code, and the database enforces it for every query — whether it comes from your React app, your mobile app, a CLI tool, or a direct API call.
Action Checklist
Work through this list for every Supabase project you've shipped or are currently building:
- Run the RLS audit query against your database and list every table where
rowsecurity = false - Enable RLS on every public-schema table that's accessible via the API
- Write policies for all four operations: SELECT, INSERT, UPDATE, DELETE — not just SELECT
- Check your
.envfiles: isSUPABASE_SERVICE_KEYorservice_rolekey exposed anywhere in frontend code? - Search your Git history: run
git log -S 'service_role'to check if the key was ever committed - Test cross-user access: log in as User A and try to query User B's data — verify you get empty results
- Enable Supabase Security Advisors in your dashboard to get weekly misconfiguration scans
- For new tables: if you create tables via SQL migrations rather than the dashboard, add
ENABLE ROW LEVEL SECURITYto your migration script - Document your policies: add a comment to each policy explaining who it's meant to protect and why
Ask The Guild
Have you audited your Supabase app's RLS configuration? Drop into the guild and share what you found:
- For the nervous: "I just ran the audit query — here's what came back."
- For the curious: What's the most complex RLS policy you've written? Multi-tenant, organization hierarchies, time-based access — show us.
- For the experienced: What's the most dangerous RLS misconfiguration you've seen in the wild — and how did you catch it before it became a breach?
Row Level Security is one of those features that seems optional until the moment it isn't. The vibe-coding wave produced some incredible apps — and a wave of exposed databases that didn't need to be. You now know exactly how to be on the right side of that line.
Enable RLS. Write your policies. Audit before you ship.