What Row-Level Security Is, and Why Every Table Needs It Enabled Explicitly
Row-Level Security is a native PostgreSQL feature that attaches access rules to individual rows of a table, evaluated by the database on every query. Supabase's entire public-API security model depends on it being switched on, table by table, by you — it is not on by default, and no AI builder turns it on automatically just because you asked for a "users" table.
Here is the mechanism. Supabase exposes your Postgres schema through an auto-generated REST API (PostgREST) and realtime subscriptions. Every request that hits that API authenticates as one of a small number of Postgres roles — typically anon for unauthenticated requests and authenticated for logged-in users. Those roles are granted table-level SELECT/INSERT/UPDATE/DELETE privileges broadly, by design, so the API works at all. Row-Level Security is the layer that narrows those broad grants down to which rows a given request is actually allowed to touch. Without it, a grant is all-or-nothing at the table level — if the role can SELECT from a table, it can select every row in it.
Two commands matter, and both are per-table:
-- Step 1: turn on row security for this table (off by default)
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
-- Step 2: define at least one policy — with RLS on and zero policies,
-- Postgres defaults to deny-all for every role except the table owner
CREATE POLICY "users can view their own orders"
ON public.orders
FOR SELECT
USING (auth.uid() = user_id);Two details are worth being precise about, because they are exactly where vibe-coded schemas go wrong. First, enabling RLS and writing a policy are two separate steps, and a policy defined on a table where RLS was never enabled is inert — Postgres never evaluates it. Second, RLS defaults to deny, not allow: the instant you enable it on a table with no policies, every request from a non-owner role is rejected. That second fact is precisely why so many "quick fix" moments in an AI prompting session end with a wide-open USING (true) policy — the fastest way to make a "permission denied" error disappear is to grant everything, and the AI (like a rushed junior engineer) will often reach for that fix first.
FORCE ROW LEVEL SECURITY option). In a standard Supabase project this rarely matters day-to-day, since your application traffic runs as anon/authenticated, not the owning postgres role — but it matters a great deal for the service_role key, covered later in this checklist.Why AI App Builders Get RLS Wrong Systematically
AI app builders are optimized to produce a working, demoable app as fast as possible, and "permissive by default" is very often the fastest path there — while a correct, restrictive policy set requires a deliberate step nothing in the prompting loop forces you to take. This is not a criticism of any one tool — Lovable, Bolt.new, Replit, v0, and Base44 all share the same underlying dynamic, and Frenchy Digital is itself a Lovable-first agency for building new apps. The pattern shows up for structural reasons, not because any particular tool is careless.
- The fastest way past a permission error is to remove the permission check.: When a prompt like "let users see their orders" produces a 401 or an empty result during testing, the most direct in-context fix is a broad policy — or disabling RLS entirely — rather than diagnosing which condition is wrong.
- You are almost always your own only test user.: While building, you're logged in as the one account that created the data, so an over-permissive policy is invisible: everything looks correct because there is no second identity to reveal what else the policy would let through.
- Schemas grow incrementally across dozens of prompts, with no natural checkpoint.: A table added in an early session to support one feature rarely gets revisited when a later session changes what "authorized" means for related data. A professional engineering process has a re-audit gate; a solo prompting session usually doesn't.
- The AI has no persistent model of your entire schema's security posture.: Each prompt is answered in the context it's given; a builder asked to "add a comments feature" isn't reliably going to re-check the RLS policies of every other table for how they interact with the new one.
None of this is fatal — it just means RLS auditing has to be a deliberate, separate step you schedule, not something you assume happened as a side effect of building features. Our post-launch checklist covers where this fits alongside your other pre-launch tasks, and our overview of hidden security risks in vibe-coded apps covers this alongside the other categories worth checking before real users touch the app.
Patterns 1 & 2: Row-Level Security Never Enabled, and Leftover Permissive Policies
Pattern 1 — RLS never enabled. A table exists, may even have policies defined in the migration history, but row security itself was never switched on — so every policy is dead code and the table is exactly as open as it would be with no policies at all. This is the single most common finding in vibe-coded schemas: CREATE TABLE statements generated by an AI builder don't automatically include the ENABLE ROW LEVEL SECURITY line, and a table created to "just get the data model working first" often keeps that unprotected state straight through to production.
-- Anti-pattern: table created, policy even exists, but RLS was never enabled
CREATE TABLE public.customer_notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id),
note text,
created_at timestamptz DEFAULT now()
);
CREATE POLICY "users can view their own notes"
ON public.customer_notes
FOR SELECT
USING (auth.uid() = user_id);
-- This policy is never evaluated: row security isn't enabled on the table,
-- so any request with a valid anon/authenticated key can read every row.
-- Fix: enable row security, then the existing policy takes effect
ALTER TABLE public.customer_notes ENABLE ROW LEVEL SECURITY;The detection query is one line, and it's the first thing to run in any audit: SELECT relname, relrowsecurity FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r';. Any row where relrowsecurity is false is a table serving data (or accepting writes) with zero row-level protection, regardless of whatever policies appear elsewhere in your schema files.
Pattern 2 — leftover permissive policies. RLS is correctly enabled, and a policy exists — but the policy's condition always evaluates to true, granting the access it governs to every request regardless of who is asking. Writing USING (true) temporarily is completely legitimate — it's the fastest way to unblock a "permission denied" error while wiring up a feature. The failure mode isn't writing it; it's forgetting to come back and tighten it once the feature works, especially because a passing demo gives you no signal anything is wrong.
| Query | What to look for |
|---|---|
| SELECT relname, relrowsecurity FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r'; | Any relrowsecurity = false is Pattern 1: RLS disabled entirely. |
| SELECT schemaname, tablename, policyname, cmd, qual FROM pg_policies WHERE schemaname = 'public'; | Read the qual column for every row. If it's true, or always-true for realistic inputs, that's Pattern 2. |
This pattern is dangerous specifically because it's silent — the app works perfectly for every legitimate user, and the only way it surfaces is someone (ideally you, in an audit, rather than an attacker) explicitly reading each policy's condition in plain English before it reaches production.
Patterns 3 & 4: Policies That Check the Wrong Condition, and Missing Policies on New Tables
Pattern 3 — the wrong condition. A policy exists and looks reasonable at a glance, but its condition compares against a value the client supplies — a header, a query parameter, a hidden form field — instead of auth.uid(), the one value the server derives independently of anything the caller sends. This is subtler than a bare USING (true) and more likely to survive a casual review, because the policy does restrict access to something — it just restricts it to whatever the request claims about itself, which a modified client is free to lie about.
-- Anti-pattern: policy trusts a client-controlled header, not the verified JWT
CREATE POLICY "customers can view their own invoices"
ON public.invoices
FOR SELECT
USING (
customer_id = (current_setting('request.headers', true)::json ->> 'x-customer-id')
);
-- Any caller can set the x-customer-id header to any value they want.
-- Fix: anchor the check to auth.uid(), derived server-side from the
-- verified JWT — the client cannot influence this value
CREATE POLICY "customers can view their own invoices"
ON public.invoices
FOR SELECT
USING (customer_id = auth.uid());The same anti-pattern shows up in less obvious shapes: a policy that checks a role or is_admin column on a table the client itself can update through a normal UPDATE request (letting a user grant themselves admin), or a multi-tenant policy that trusts an organization_id passed as a query parameter rather than one derived from a verified membership row. The unifying question for every policy is simple: does this condition depend on anything the caller supplies, or only on what the server independently verified? Only the latter is a real access control.
Pattern 4 — missing policies on new tables. Midway through a long prompting session, the AI creates a new table to support a feature — a notifications table, a saved_searches table — and either forgets to enable RLS on it (Pattern 1 again) or enables RLS with no policies at all, which locks the table down completely and typically surfaces as a confusing bug rather than a security gap. This deserves its own entry because it's a process failure as much as a configuration one: the table missing protection today is the one that didn't exist when you last ran a security pass.
Patterns 5 & 6: Storage Buckets Left Public, and Service-Role Keys Used Client-Side
Pattern 5 — public storage buckets. User-uploaded files — avatars, documents, receipts — sit in a Supabase Storage bucket that's either flagged Public at the bucket level, or has a permissive policy on the underlying storage.objects table, so anyone with (or without) an account can list or fetch files never meant to be public. Storage objects are themselves rows in a Postgres table and governed by the same RLS model as any other table — but a bucket can also be marked "public" at creation, which serves every object over a predictable URL and skips policy evaluation for reads entirely. Both settings need checking independently; either one alone is enough to leak files.
-- Anti-pattern: a wide-open policy on storage.objects
CREATE POLICY "public access to all files"
ON storage.objects
FOR ALL
USING (true);
-- Fix: scope access to files inside the caller's own folder, using the
-- folder-per-user convention (bucket_id/user_id/filename)
CREATE POLICY "users manage their own files"
ON storage.objects
FOR ALL
USING (
bucket_id = 'user-uploads'
AND auth.uid()::text = (storage.foldername(name))[1]
)
WITH CHECK (
bucket_id = 'user-uploads'
AND auth.uid()::text = (storage.foldername(name))[1]
);Check bucket-level visibility separately in the dashboard's Storage settings, or via SELECT id, name, public FROM storage.buckets; — a bucket with public = true bypasses policy checks for reads no matter how the storage.objects policies are written.
Pattern 6 — service-role keys client-side. The service_role key — meant strictly for trusted server-side code — ends up embedded in frontend JavaScript, a mobile app binary, or any bundle shipped to end-user devices, which means every RLS policy in the project becomes irrelevant for anyone who extracts that key. This deserves to be taken more seriously than any single misconfigured policy, because it isn't a gap in one table's protection — it's a bypass of the entire security model at once.
A permission error from the anon key is the RLS system working correctly and telling you a policy needs attention. Switching to the service-role key to silence it removes the protection instead of fixing it.
Both keys sit right next to each other in your Supabase project's API settings, which is part of why this mistake happens — the service-role key is the one that makes local development friction disappear immediately. Checking your bundled app or web build for this key (via browser devtools' Network/Sources tabs, or by grepping a decompiled mobile binary) belongs in every audit, covered next.
The Self-Audit Checklist: What to Run Today
You can catch the majority of real-world RLS failures in under an hour with four SQL queries, a second test account, and your browser's devtools — no paid tooling required for a first pass.
- 1.List every table.: SELECT tablename FROM pg_tables WHERE schemaname = 'public'; — treat this as your audit scope. Anything not on this list didn't get checked.
- 2.Confirm RLS is enabled on every one of them.: Run the pg_class.relrowsecurity query from Pattern 1 and get every row to true before anything else.
- 3.Read every policy on every table, aloud, and ask "what happens if a malicious authenticated user tries this?": Pull the full list with pg_policies and say each condition in plain English. If the answer is "...if true" or "...if they claim to be the right customer_id," that's a finding.
- 4.Create a second real user account and test cross-account access directly.: Not an admin account — a genuinely separate, unprivileged user. Attempt to read, edit, and delete the first user's data both through the app's UI and with a raw REST call (anon key plus the second user's access token) against every table's endpoint.
- 5.Check the browser Network tab and your app bundle for exposed keys.: Confirm only the anon/publishable key appears anywhere reachable by a user — never service_role. For mobile apps, check the compiled binary or bundled JS, since a key added during debugging can survive into a shipped build.
- 6.Check every storage bucket's public flag and every policy on storage.objects.: A private-looking bucket with a permissive policy leaks exactly as much as a bucket flagged public.
- 7.Find every SECURITY DEFINER function and read its body.: These intentionally bypass the caller's RLS; each one needs its own explicit authorization check written inside it, not inherited from the table it touches.
- 8.Re-run steps 1-3 after every significant feature addition.: A schema built across dozens of AI prompting sessions accumulates new tables continuously; the audit needs to be a recurring habit tied to shipping, not a single pre-launch event.
This list overlaps deliberately with the broader pre-launch work covered in our post-launch checklist and the production-readiness work in finishing a vibe-coded app the right way — RLS is one section of a larger readiness pass, not a substitute for it.
How a Professional RLS Audit Differs From a DIY Pass
The checklist above catches the obvious, per-table failures. A professional audit adds systematic tooling, genuinely adversarial testing across accounts, and — the part that's hardest to do yourself — checking how policies on different tables interact, since a correct-looking policy on one table can still leak data through a join, a view, or a related table's own gap.
| Dimension | DIY self-audit | Professional audit |
|---|---|---|
| Coverage | Table-by-table, as time allows | Every table, every policy, every storage bucket, systematically enumerated and tracked |
| Testing method | Manual, with one second account | Scripted adversarial testing across multiple simulated identities and roles |
| Cross-table interactions | Rarely checked — hard to see without full schema context | Explicitly mapped: joins, views, and foreign-key relationships checked for indirect leakage |
| SECURITY DEFINER functions | Easy to miss entirely | Enumerated and each one's internal auth logic reviewed line by line |
| Multi-tenant isolation | Spot-checked | Tested for cross-tenant leakage on every shared table |
| Deliverable | Whatever notes you keep | Written findings with severity, plus tested, rewritten policies |
The cross-table case is worth an example, because it's the category a solo table-by-table pass structurally can't catch: a comments table might have a perfectly correct policy scoping rows to auth.uid() = author_id, but if it's exposed through a view that joins in the parent posts table, and that view doesn't inherit or re-check the posts' own visibility rules, a user can end up reading comments on posts they were never authorized to see in the first place. No single table's policy is wrong in isolation; the leak only exists at the intersection.
This is exactly the scope of Frenchy Digital's Vibe Code Health Check ($1,500-$3,500, 3-5 business days), which includes a full RLS policy audit across every table, and — when the findings warrant actually rewriting and testing policies rather than just documenting them — the Vibe Code Stabilization Sprint ($5,000-$15,000, 1-3 weeks). Full detail on what's included at each pricing tier is in our 2026 pricing guide; if you're evaluating whether to fix the current app or start fresh, our technical debt framework and our vibe coding vs. professional development comparison are the right next reads.
Why Frenchy Digital
Frenchy Digital runs full Row-Level Security audits across Supabase and Postgres-backed schemas for apps built on Lovable, Bolt.new, Replit, Bubble, and other AI builders — reading every table, every policy, and every storage bucket, and testing access with a genuine second user account rather than assuming the schema is safe because the demo works. Based in Los Angeles with international teams in Geneva, Switzerland and Paris, France, we bring the same product discipline to auditing an existing Supabase database that we apply as a Lovable-first agency building new apps from scratch.
What a Frenchy Digital RLS Engagement Covers
- Full schema enumeration: every table checked for relrowsecurity, every policy's qual expression read and translated to plain English.
- Adversarial cross-account testing: a genuine second user account probing every table's REST endpoint, not just the app's UI.
- Storage bucket audit: bucket-level public flags and storage.objects policies checked independently.
- SECURITY DEFINER function review: every function's internal authorization logic read line by line.
- Cross-table and view leakage checks: the class of bug a table-by-table DIY pass structurally can't catch.
- Written findings with severity, plus — in a Stabilization Sprint — rewritten and re-tested policies, not just a list of problems.
If you built on Supabase through an AI app builder and have never had someone besides the AI itself review your policies, the checklist in this article will find most of your obvious exposure today. What it can't replicate on its own is the adversarial, cross-table thoroughness of a structured audit — which is where a second set of eyes, and a second real test account, earns its cost before real user data is on the line.
Ready to find out exactly what your RLS policies actually allow? Schedule your free discovery call and we'll walk through what a full Vibe Code Health Check on your Supabase schema would cover before you commit to anything.
Ready to Build Your App?
Schedule a free strategy consultation with our team to discuss your project.
1517 S Bentley Ave Unit 204, Los Angeles CA 90025

