RLS enabled is not the same as RLS correct
The common Supabase advice is to turn on Row Level Security for every table. That advice is right, and the companion note on Supabase RLS defaults covers the case where RLS is off and the table is wide open. This note is about the next failure, the one that survives that fix: RLS is enabled, a policy exists, the dashboard shows a green checkmark, and the table still leaks.
The supabase RLS policy pitfalls in this article all compile, all pass a casual look, and all return rows the caller should not see. The reason is that RLS does not enforce a meaning. It enforces a boolean expression you wrote. If that expression evaluates to true for the wrong caller, Postgres returns the row, exactly as instructed. A policy that is permissive, scoped to the wrong identity, or missing for one operation is still an active policy. It just allows the wrong thing.
The trust boundary here is authorization, not authentication. The anon key authenticates a request as the anonymous role, and a logged-in user authenticates as the authenticated role with a known user id. Both are working correctly. The question a policy answers is which rows each of those callers may read or change, and that is where these patterns go wrong.
Pattern 1: USING (true) leaves the table open with RLS on
Why it looks correct: RLS is enabled, the table shows a policy, and the app works. A reviewer skimming the dashboard sees an enabled flag and a named policy and moves on.
What actually leaks: USING (true) is the policy body. USING is the row-visibility filter, and true matches every row. So the policy says "every row is visible to whoever this policy applies to". If the policy targets the public or anon role, the table is fully readable by any visitor holding the anon key, which ships in the client bundle of every deployed Supabase app. The effect is identical to having no RLS at all, but it is harder to spot because the table now looks protected.
This often appears because a generator added a placeholder policy to make a query stop failing, then never tightened it.
The fix: replace the body with a condition that names the owning user. For a table with a user_id column, the owner read policy is USING (auth.uid() = user_id). Now the row is visible only when the authenticated caller's id matches the row's owner column, and the anon caller (whose auth.uid() is null) matches nothing.
Pattern 2: a SELECT policy but no INSERT, UPDATE, or DELETE policy
Why it looks correct: someone wrote a careful owner-scoped SELECT policy, tested that reads return only the user's own rows, and called it done. Reads are locked down and visibly correct.
What actually leaks: RLS policies are per command. A SELECT policy governs reads only. With RLS enabled and no INSERT, UPDATE, or DELETE policy, those operations are denied by default, which is the safe outcome. The danger is the reaction to that denial. The app's insert call starts failing, and the fastest way to make it work again is to add a permissive catch-all such as a policy FOR ALL USING (true) WITH CHECK (true). That single line re-opens read, write, and delete for the whole table and silently overrides the careful SELECT policy, because Postgres combines multiple permissive policies for a command with OR. One true makes the whole thing true.
The fix: write one policy per operation you actually need, each scoped to the owner. A WITH CHECK clause governs the row a caller is allowed to write, so an INSERT policy uses WITH CHECK (auth.uid() = user_id) to stop a user inserting rows owned by someone else. Never use a FOR ALL USING (true) policy to clear an error.
Pattern 3: auth.role() where you meant auth.uid()
Why it looks correct: the policy references an auth helper, mentions a role, and reads like an access rule. USING (auth.role() = 'authenticated') looks like it is checking the right thing.
What actually leaks: this checks the caller's role, not which user they are. Every logged-in user has the role authenticated, so this condition is true for all of them at once. The result is that any signed-in user can read every row in the table, including rows belonging to other users. The login wall is intact, so it feels secure, but there is no per-user scoping at all. This is the classic confusion between authentication (are you logged in) and authorization (which rows are yours). It is especially easy to ship in a multi-tenant app, where it reads as "only members can see this" but actually means "any member can see everyone's data".
The fix: check identity, not role. Use USING (auth.uid() = user_id) so the row is tied to the specific user who owns it. Use auth.role() only when you genuinely mean "any authenticated user", for example a shared reference table that every logged-in user is meant to read.
Pattern 4: SELECT is scoped but UPDATE and DELETE are not
Why it looks correct: the SELECT policy is the owner pattern, USING (auth.uid() = user_id), and reads are correctly private. Because reads are the visible behavior in most UIs, the table appears fully locked down.
What actually leaks: writes are governed by their own policies, and a USING clause on an UPDATE or DELETE policy decides which existing rows the caller may target. If the UPDATE policy is USING (true), any caller the policy applies to can update any row in the table, not just their own. Even worse, an UPDATE policy that scopes USING correctly but omits WITH CHECK lets a user change a row they own and reassign its user_id to someone else, moving the row out of their reach or into a victim's account. A DELETE policy with USING (true) lets a logged-in user delete other people's rows. None of this shows up while you click around your own account, because you only ever act on your own data during testing.
The fix: scope every write the same way you scoped the read. For UPDATE, set both USING (auth.uid() = user_id) to limit which rows can be targeted and WITH CHECK (auth.uid() = user_id) to limit what the row may become. For DELETE, set USING (auth.uid() = user_id). Test writes as a second user, not just reads as yourself.
Check each policy, not just the enabled flag
You can review this on your own project without any attack. Two paths, both run against your own database.
From the Supabase dashboard: open Authentication, then Policies, and read the actual body of each policy, not just the enabled flag. For every table that holds user, account, payment, or admin data, ask three questions. Does each operation you use (select, insert, update, delete) have a policy. Does each policy's USING or WITH CHECK reference the owning user (auth.uid() = user_id or an equivalent membership check), and not auth.role() or a bare true. Is there any FOR ALL or USING (true) policy that quietly re-opens the table.
From behavior, with two test accounts: sign in as user A and create a row. Sign in as user B and try to read, update, and delete user A's row through the app or the REST endpoint, using only your own test data.
- Suspicious result: a policy body of true, a condition on auth.role() instead of auth.uid(), a SELECT-only table whose writes you assumed were blocked, or an UPDATE or DELETE policy without an owner condition.
- Safer result: each operation has its own policy, each names the owning user, and UPDATE policies carry both USING and WITH CHECK.
- Suspicious result: user B can see, change, or delete user A's row. That is a live authorization gap.
- Safer result: user B gets an empty result or a permission error for user A's row. That is the expected deny.
Where VibeCodeGuard fits, and what it cannot settle
VibeCodeGuard's Launch Check works from the public surface. It can detect an RLS-disabled or anon-readable table by querying the anon-key REST endpoint and seeing whether rows come back to an anonymous caller. That catches Pattern 1 when the table is exposed to the anon role, and it catches the fully open table from the companion RLS-defaults note. Those are observable from outside with the public key alone.
What a public scan cannot do is read your policy logic. Whether an enabled policy is actually correct, the auth.uid() versus auth.role() confusion, the missing UPDATE scope, the catch-all that overrides a careful SELECT, is a logic question about rows tied to a logged-in identity. Settling it requires reading the policy bodies and testing as a second authenticated user, which is manual review inside your project, not something an unauthenticated scan of the public URL can determine. State this plainly to yourself before you treat a clean scan as a clean table.
Next step: open Authentication, then Policies, and read the body of every policy on a table that holds private data, checking it against the five patterns above. Then run a focused Launch Check against the public URL before sharing it, so an anon-readable table shows up in your scan and not in someone else's.
A clean Launch Check tells you the table is not openly readable by an anonymous caller. It does not tell you that a logged-in user cannot read or change another user's rows. The first is an external, observable fact; the second lives in your policy logic and needs manual review with multiple test accounts.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.