How RLS actually works

Row Level Security is a PostgreSQL feature that Supabase exposes through its dashboard. When RLS is enabled on a table, every query against that table is automatically filtered by one or more policies you define. The policies are expressions evaluated per row, per user.

The function that does most of the work is auth.uid(). It returns the UUID of the currently authenticated user as determined by the JWT Supabase passes with every request. A typical read policy looks like this: only return rows where the row's user_id column matches the UUID of the person making the request.

That sounds simple. The complexity comes from three things that trip up almost every vibe-coded app:

  • RLS has separate policies for SELECT, INSERT, UPDATE, and DELETE. Protecting reads does not protect writes.
  • The anon role and the authenticated role are distinct. A policy written for authenticated does nothing for a request made by an unauthenticated visitor.
  • A table with RLS enabled but zero policies returns zero rows for read requests and rejects writes silently from the anon key, but the service role bypasses all of this entirely.
61%
of vibe-coded Supabase apps we audit have at least one RLS misconfiguration
3
tables on average with missing or permissive write policies
P0
severity rating for service role key found in client-side code

The anon key model — what Supabase actually exposes

Every Supabase project has two main API keys: the anon key (safe to use in client-side code) and the service role key (should never leave your server). The anon key identifies requests as coming from the anon Postgres role. Once a user authenticates, Supabase issues a signed JWT and subsequent requests from that user are identified as the authenticated role with their specific auth.uid().

RLS policies control what each role can do. The anon key is designed to be public — it cannot itself protect your data. RLS is what protects your data. This is a conceptual shift from other database-as-a-service products, and it's the one most often missed.

i

The mental model. Think of the anon key as your front door being unlocked. RLS is the individual locks on every room inside. Without RLS, an unlocked front door means everything is accessible. With correct RLS, the front door being unlocked is fine — each room only opens for the right person.

Failure 1: RLS enabled with no policies — the deny-all that looks like security

When you enable RLS on a table and create no policies, Postgres denies all access to that table for the anon and authenticated roles. Your app appears to work — read requests return empty arrays rather than errors — but nothing is actually stored or retrieved correctly.

This is surprisingly common in AI-generated code. The AI enables RLS because it knows it should, then generates the application code without creating the corresponding policies. The app functions in development (where the developer is often using the service role key through the Supabase CLI) but fails silently in production when users try to access their own data.

BAD — RLS enabled, no policies created
-- RLS is on for this table: ALTER TABLE documents ENABLE ROW LEVEL SECURITY; -- But there are no policies. Result: authenticated users -- querying their own documents get an empty array. -- They may interpret this as "no documents yet" rather -- than a permission failure. Data is silently inaccessible.
GOOD — RLS enabled with explicit read and write policies
ALTER TABLE documents ENABLE ROW LEVEL SECURITY; -- SELECT: users can only read their own rows CREATE POLICY "Users read own documents" ON documents FOR SELECT TO authenticated USING (auth.uid() = user_id); -- INSERT: users can only create rows owned by themselves CREATE POLICY "Users insert own documents" ON documents FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id); -- UPDATE: users can only modify their own rows CREATE POLICY "Users update own documents" ON documents FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); -- DELETE: users can only delete their own rows CREATE POLICY "Users delete own documents" ON documents FOR DELETE TO authenticated USING (auth.uid() = user_id);

To check whether your tables have policies defined, run this in the Supabase SQL editor:

CHECK — find tables with RLS on but no policies
SELECT t.tablename, t.rowsecurity AS rls_enabled, COUNT(p.policyname) AS policy_count FROM pg_tables t LEFT JOIN pg_policies p ON p.tablename = t.tablename AND p.schemaname = t.schemaname WHERE t.schemaname = 'public' GROUP BY t.tablename, t.rowsecurity HAVING t.rowsecurity = true AND COUNT(p.policyname) = 0 ORDER BY t.tablename;

Any table appearing in that result set has RLS enabled but is completely inaccessible to authenticated users — and completely invisible to the anon role. If you have users, they can't see their data.

Failure 2: Permissive USING(true) — the policy that permits everything

USING(true) is the most dangerous single line you can put in a Supabase policy. It creates a permissive policy that approves every row for every user. It is indistinguishable from having no RLS at all for read access.

AI code generators produce this pattern regularly. They know a SELECT policy is needed, they write one, and they use true as a placeholder expression intended to be replaced. The replacement never happens.

P0 Permissive RLS policy — all rows accessible to all authenticated users supabase/migrations/001_init.sql:47

A USING(true) expression on the profiles table grants every authenticated user read access to every row. This exposes all user profile data — including email addresses, phone numbers, and any PII stored in that table — to any logged-in user.

Fix: Replace USING(true) with USING(auth.uid() = user_id) to restrict each user to their own row.

The correct fix is always to replace true with a meaningful expression. For per-user isolation, that's auth.uid() = user_id. For team-based access where multiple users share data, it might involve a join to a membership table. true is never the right answer unless you genuinely want public read access — and if you do, say so explicitly in the policy name so it's obvious during review.

Failure 3: Silent type mismatch — the policy that always returns false

This failure is the hardest to debug because the policy appears syntactically correct. The issue is that auth.uid() returns a value of type uuid, and if your user_id column is typed as text, the comparison auth.uid() = user_id always evaluates to false without throwing an error.

From the user's perspective, their data is inaccessible. From a developer's perspective looking at the policy, everything looks right. The Supabase dashboard doesn't flag it. Postgres silently applies the implicit type coercion rules and finds no match.

P1 RLS type mismatch — auth.uid() (uuid) compared to user_id (text) always false supabase/migrations/002_rls.sql:12

The orders table has a user_id TEXT column. The RLS policy compares auth.uid() = user_id. Since auth.uid() is of type uuid and user_id is text, this comparison silently returns false for every row. No authenticated user can read, update, or delete any order.

Fix: Alter the column type — ALTER TABLE orders ALTER COLUMN user_id TYPE uuid USING user_id::uuid; — or cast in the policy: USING(auth.uid()::text = user_id).

The safest approach is to define all user ID columns as uuid from the start, matching the output type of auth.uid(). If you've already shipped with a text column, casting in the policy is a quick fix, but migrating the column type eliminates the class of error entirely.

Failure 4: Missing write policies — reads protected, writes open

RLS policies are operation-specific. A SELECT policy does not cover INSERT, UPDATE, or DELETE. A policy with FOR ALL covers all operations, but even then, INSERT uses WITH CHECK, not USING. These are distinct clauses that serve different purposes: USING filters existing rows, WITH CHECK validates new or modified rows before they're written.

The result: an app can have perfectly correct SELECT policies — users only see their own data — but any authenticated user can INSERT rows owned by a different user, or UPDATE or DELETE rows that don't belong to them, because no write policy exists.

This is particularly dangerous for tables that store sensitive data in aggregate. A user who can INSERT arbitrary rows into a payments table — even if they can't read other users' payment records — can pollute your data integrity in ways that are expensive to unwind.

!

Common misconception. FOR ALL in a policy does cover SELECT, INSERT, UPDATE, and DELETE — but INSERT validation requires a WITH CHECK clause, not just USING. If you write FOR ALL USING(auth.uid() = user_id) without a WITH CHECK, INSERT operations are not correctly constrained on the new row's values. Always pair USING with WITH CHECK on tables that accept writes.

Failure 5: Service role key in client-side code — RLS bypassed entirely

The service role key bypasses RLS entirely. Postgres treats requests made with the service role key as superuser queries — no policy is evaluated. This is intentional: it lets your backend do administrative operations without policy constraints. The problem is when this key ends up in client-side code.

AI code generators produce this pattern when asked to "fix" an RLS issue. The fastest way to make a Supabase query return data when a policy is blocking it is to swap the anon key for the service role key. The AI does this without flagging that the key is now exposed in a browser bundle that any visitor can inspect.

P0 Service role key exposed in client bundle — full database access, no RLS src/lib/supabase.ts:4

The SUPABASE_SERVICE_ROLE_KEY value is passed to createClient() in a file that is included in the browser bundle. Anyone who opens DevTools can extract this key and use it to read, write, or delete any row in the database, bypassing all RLS policies.

Fix: Remove the service role key from client-side code immediately. Use the anon key in the browser. Move any operations that require elevated privilege to a server-side function or Edge Function where the key is never exposed.

The service role key must never appear in any file that is compiled into a client-side bundle. In Next.js, this means it should only be referenced in files under app/api/, pages/api/, or server components — never in files that are imported by client components. The naming convention NEXT_PUBLIC_ is a useful proxy: if a variable has that prefix, it is in the bundle, and the service role key must not have it.

How to test RLS — not just review it

Reading your policy definitions is necessary but not sufficient. Policies can look correct and still fail due to type mismatches, role targeting errors, or policy interaction effects. The only reliable test is to execute queries as each role and verify the results match your expectations.

Test as the anon role

In the Supabase SQL editor, you can impersonate a role using SET ROLE. This lets you verify what an unauthenticated visitor can access:

Test — verify anon role access
-- Run this in the Supabase SQL Editor -- It simulates what an unauthenticated visitor can read SET ROLE anon; SELECT * FROM documents LIMIT 5; -- Expected: 0 rows (or only explicitly public documents) -- If you see data here, your RLS is not protecting it SELECT * FROM profiles LIMIT 5; -- Expected: 0 rows -- If you see user profiles, any visitor can read them RESET ROLE;

Test as a specific authenticated user

To test that a user can only access their own data, use SET LOCAL role and SET LOCAL request.jwt.claims to impersonate a specific user session:

Test — impersonate a specific user and verify isolation
-- Replace USER_UUID_A and USER_UUID_B with real user IDs from auth.users BEGIN; -- Set the JWT claims to simulate user A being logged in SET LOCAL role TO authenticated; SET LOCAL request.jwt.claims TO '{"sub": "USER_UUID_A", "role": "authenticated"}'; -- User A should only see their own documents SELECT id, user_id, title FROM documents; -- Expected: only rows where user_id = USER_UUID_A -- Now try to access a document owned by user B directly -- This simulates an IDOR attempt SELECT * FROM documents WHERE user_id = 'USER_UUID_B'; -- Expected: 0 rows — the policy should filter this out -- If you see rows here, your USING clause is not working ROLLBACK;

Automate these tests. The SQL blocks above can be wrapped in pgTAP tests or called from a Jest/Vitest test suite via the Supabase client initialised with different user sessions. Running them in CI means any future schema or policy change that breaks isolation fails the build before it reaches production.

The difference between enabling RLS and having working RLS

The Supabase dashboard shows a green shield icon next to tables with RLS enabled. That icon tells you RLS is on. It tells you nothing about whether your policies are correct, complete, or actually enforcing the isolation you intend.

The distinction matters because the two states look identical from the dashboard, from your application code, and often from end-user behavior. A table with USING(true) shows a green shield. A table with a type mismatch shows a green shield. A table with correct read policies but no write policies shows a green shield.

What actually tells you whether RLS is working:

  • Running the role-impersonation tests above and verifying the results match your intended access model
  • Querying pg_policies to confirm every table has policies for every operation type you use
  • Checking column types against the return type of auth.uid() (which is always uuid)
  • Auditing your client-side code for any use of the service role key
  • Verifying that INSERT and UPDATE policies include both USING and WITH CHECK clauses

None of this is visible in the dashboard. All of it is checkable in under 30 minutes with the right queries. The RLS checker at /tools/rls-checker.html runs through these checks automatically against your project if you connect it with a read-only role.

Get your RLS findings in 60 seconds

We check RLS policies, type compatibility, write coverage, and service key exposure on every free scan. No account required.

What a correct RLS setup looks like in full

To summarize the model that avoids all five failure modes: every user-facing table has RLS enabled. Every table has explicit policies for SELECT, INSERT, UPDATE, and DELETE — never relying on defaults. Every policy expression uses auth.uid() compared to a uuid-typed column. INSERT and UPDATE policies pair USING with WITH CHECK. The anon key is used in client-side code; the service role key is used only in server-side functions and never exported to a bundle.

You test this by impersonating roles in SQL before deployment and by running integration tests that assert cross-user data isolation as part of every CI run.

The Moltbook breach in 2025 — 47,000 user records exposed — was caused by RLS being enabled but unenforced on the tables that stored document content. It was discovered not by an internal audit but by a security researcher who noticed the anon key returned data it shouldn't. The full case study is here.

Supabase RLS is one of the most effective data isolation mechanisms available to indie developers. It is also one of the easiest to misconfigure in ways that are invisible until they aren't. Run the tests above before you hand over your first paying customer's data.