Supabase Row Level Security (RLS) Setup Guide 2026: Production Patterns That Actually Work
Row Level Security is the most important Supabase feature that most developers enable too late, configure too loosely, or skip entirely until a security audit forces their hand. At WebVerse Arena, we've audited and retrofitted RLS on a dozen applications built by teams who treated it as optional — and in every case, the database had cross-tenant data exposure that would have been catastrophic in production. This guide is the technical RLS reference we wish had existed when we started: the patterns that work, the mistakes that are easy to make, and the testing approach that catches policy gaps before users do.
The foundational setup: enabling RLS on a table is a single command — `ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;` — but this alone does nothing useful except lock every row from every user by default. The critical companion step is creating policies that define exactly which rows each authenticated (or anonymous) user can see, insert, update, and delete. Common mistake #1 is assuming that enabling RLS without any policies means 'deny all' — it does, but only for roles other than `postgres` and `service_role`. The Supabase `service_role` key bypasses RLS entirely, which means any backend code using the service role key sees all rows regardless of your policies. This is intentional and correct for admin operations — but if your Next.js API routes are using `createClient(url, SERVICE_ROLE_KEY)` for user-facing queries, you have no tenant isolation regardless of how carefully you wrote your policies.
The four RLS policy patterns we deploy across every project: User-owns-row (`auth.uid() = user_id`) is the simplest — a user can only read, update, and delete rows where the `user_id` column matches their authenticated UID. Org-scoped (`auth.jwt() ->> 'org_id' = org_id::text`) uses a custom JWT claim to scope access to all rows belonging to the user's organisation — requires injecting the claim at login time via a Supabase Auth hook or a database function triggered on sign-in. Role-based (`(auth.jwt() ->> 'role') = 'admin'`) allows elevated users to access rows that regular users cannot — we combine this with org-scoped policies using `OR` so admins see all org rows while members see only their own. Soft-delete-aware (`deleted_at IS NULL`) filters logically deleted rows from all standard SELECT policies — critical for applications that soft-delete rather than hard-delete records, where a naive policy would expose 'deleted' data to users who query directly via the Supabase client SDK.
Performance is the RLS concern that comes up most in production. The short version: bad RLS policies kill query performance; good ones cost almost nothing. The difference is whether Postgres can push the RLS filter down into an index scan or has to apply it as a post-scan filter on full table results. The rule: every column referenced in an RLS policy must have an index, and the policy expression must be written in a form Postgres can use with that index. `auth.uid() = user_id` with an index on `user_id` is fast. `(SELECT user_id FROM memberships WHERE user_id = auth.uid()) = user_id` is a correlated subquery that executes once per row — catastrophic on large tables. The correct pattern for membership-based access is a `JOIN` or a properly indexed lookup with `EXISTS`: `EXISTS (SELECT 1 FROM memberships WHERE memberships.user_id = auth.uid() AND memberships.resource_id = documents.id)` with a composite index on `(user_id, resource_id)`.
Testing RLS policies is where most teams fall short — they manually test the happy path in the Supabase dashboard and call it done. The correct approach has three layers. First, Supabase CLI local development: run `supabase start` to spin up a local Postgres instance with your full schema and seed data, then use `SET ROLE authenticated; SET request.jwt.claims = '{"sub": "user-uuid-here"}';` in psql to impersonate a specific user and run queries directly — you see exactly what that user's session can access. Second, pgTAP tests: write database-level tests using the `pgtap` extension that assert specific users can and cannot access specific rows — these run in CI on every migration. Third, integration tests: in your application test suite, create test users with known UUIDs, insert rows with `user_id` set to both the test user and a different UUID, authenticate as the test user, and assert the query returns only the test user's rows. This three-layer approach catches the policy you forgot to write for UPDATE while only writing SELECT and INSERT.
The production RLS checklist we run before every Supabase project goes live: every table with user data has RLS enabled (verify with `SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'`); every policy has been tested with both the target user (should pass) and a different user (should fail); no user-facing API routes use the service role key; the `anon` role has explicit policies on any table accessible to unauthenticated users (or none, if the table should be auth-only); all policy expressions reference indexed columns; soft-deleted rows are excluded from SELECT policies; and insert policies include a `WITH CHECK` clause (not just a `USING` clause — `USING` controls which rows a user can see, `WITH CHECK` controls what they can write, and omitting `WITH CHECK` on INSERT allows users to insert rows with any `user_id` value including other users' IDs).
The `WITH CHECK` omission on INSERT is the RLS bug we find most frequently in codebases that weren't built by Supabase specialists. A policy written as `CREATE POLICY insert_own ON documents FOR INSERT USING (auth.uid() = user_id)` looks correct but is subtly wrong — `USING` on an INSERT policy is ignored by Postgres; only `WITH CHECK` applies for INSERT and UPDATE. The correct policy is `CREATE POLICY insert_own ON documents FOR INSERT WITH CHECK (auth.uid() = user_id)`. Without this, a user can insert a row claiming `user_id = 'another-users-uuid'` and Postgres will accept it — the row now belongs to the wrong user, and that user will see a document they never created. We've found this bug in production codebases with hundreds of thousands of rows. If you want us to audit your Supabase RLS setup, reach out here — we offer a fixed-price RLS audit starting at ₹25,000.
Building AI-heavy SaaS products, running a digital agency, and sharing everything I learn along the way.
Ready to build something extraordinary?
Book a free 30-minute strategy call. No pitch decks, no fluff — just a clear plan for your project.