Supabase RLS: The Copy-Paste Guide for Vibe Coders
5 working SQL patterns, Clerk JWT sync, AI gotchas, and a debugging playbook. No fluff — just SQL that works.
Quick answer: ALTER TABLE tablename DISABLE ROW LEVEL SECURITY; — that's how you disable RLS. Keep reading for the production checklist.
Quick Answer: How to Disable RLS in Supabase
You're getting a permissions error. Your app is broken. You just want to ship. Here's the escape hatch:
-- Disable RLS on a specific table (development only)
ALTER TABLE your_table_name DISABLE ROW LEVEL SECURITY;Run that in the Supabase SQL Editor (Dashboard → SQL Editor → New Query).
⚠️ Development only. Disabling RLS means anyone with your Supabase anon key can read, write, and delete every row. If your anon key is in your frontend (it is), this is a data leak waiting to happen. See the Re-enable Checklist before you go live.
Check which tables have RLS disabled right now:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';rowsecurity = false means RLS is off. That's your security audit in one query.
What Is RLS in Plain English
Imagine your database is a filing cabinet. Without RLS, anyone who has the key to the cabinet can read or edit any folder. With RLS, each folder has its own lock — and the lock checks who you are before opening.
RLS (Row Level Security) is a Postgres feature that lets you attach access rules directly to database tables. When a query hits your database, Postgres checks the policy for every row before returning it. No policy match = row doesn't exist as far as the query is concerned.
Supabase makes this practical by injecting your user's JWT into every database session. This means your policies can call auth.uid() to get the current user's ID — without any extra code. Your frontend calls Supabase → Supabase reads the JWT → Postgres enforces the policy.
The critical rule: if RLS is enabled and no policy exists, zero rows are returned. Not an error. Not a warning. Just silence.
Enable RLS: The Exact SQL
-- Enable RLS on a table
ALTER TABLE your_table_name ENABLE ROW LEVEL SECURITY;That's it. But do NOT stop here. The moment you run this with no policies attached, every query to that table returns zero rows. Your app will look completely broken. This is expected — RLS enabled + no policies = deny all.
Always add at least one policy immediately after enabling RLS:
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Then immediately add a policy (example: users can read their own rows)
CREATE POLICY "Users can read own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);⚠️ Common AI mistake: Cursor and Lovable frequently generate migration files that enable RLS but forget to include the policies. Your table gets locked down and you spend 2 hours wondering why your data vanished.
The 5 Copy-Paste RLS Patterns
These cover 90% of real-world apps. Pick what fits and copy directly.
User Owns Their Rows
The 90% use case — notes, todos, posts, profiles
Each row belongs to a single user. The policy checks that the authenticated user's JWT matches the row's owner. Your table needs a user_id column.
-- Users can only see their own rows
CREATE POLICY "Users can view own rows"
ON your_table FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert rows for themselves
CREATE POLICY "Users can insert own rows"
ON your_table FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own rows
CREATE POLICY "Users can update own rows"
ON your_table FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can only delete their own rows
CREATE POLICY "Users can delete own rows"
ON your_table FOR DELETE
USING (auth.uid() = user_id);USING controls which rows you can read/modify. WITH CHECK controls which rows you can write. For INSERT, there's no existing row to check — only WITH CHECK applies.
-- Required: your table needs a user_id column
ALTER TABLE your_table ADD COLUMN user_id UUID REFERENCES auth.users(id);Public Read, Authenticated Write
Blog posts, product listings, comments
Anyone can browse content — even unauthenticated users. Only logged-in users can create. Authors can edit and delete their own rows.
-- Anyone (even unauthenticated) can read
CREATE POLICY "Public read access"
ON posts FOR SELECT
USING (true);
-- Only authenticated users can insert
CREATE POLICY "Authenticated users can post"
ON posts FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
-- Authors can update their own posts
CREATE POLICY "Authors can edit own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Authors can delete their own posts
CREATE POLICY "Authors can delete own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);Admin-Only Access
Settings, feature flags, audit logs
Only admin accounts can read or modify rows. Uses a separate admins table to identify admin users.
-- First, create a way to identify admins
CREATE TABLE admins (
user_id UUID REFERENCES auth.users(id) PRIMARY KEY
);
-- Only admins can read
CREATE POLICY "Admin read only"
ON sensitive_table FOR SELECT
USING (
auth.uid() IN (SELECT user_id FROM admins)
);
-- Only admins can modify
CREATE POLICY "Admin write only"
ON sensitive_table FOR ALL
USING (
auth.uid() IN (SELECT user_id FROM admins)
)
WITH CHECK (
auth.uid() IN (SELECT user_id FROM admins)
);Performance note: the subquery (SELECT user_id FROM admins) runs per-row. For small admin tables this is fine. For large datasets, use a security definer function instead (see Performance Anti-Patterns).
Multi-Tenant (Org-Based Access)
Team workspaces, B2B SaaS
Users belong to organizations and can only see their org's data. Requires an org_members table linking users to orgs.
CREATE TABLE org_members (
org_id UUID,
user_id UUID REFERENCES auth.users(id),
role TEXT DEFAULT 'member',
PRIMARY KEY (org_id, user_id)
);
-- Members can read their org's data
CREATE POLICY "Org members can read"
ON projects FOR SELECT
USING (
org_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid()
)
);
-- Members can insert into their org
CREATE POLICY "Org members can create"
ON projects FOR INSERT
WITH CHECK (
org_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid()
)
);
-- Only org admins can delete
CREATE POLICY "Org admins can delete"
ON projects FOR DELETE
USING (
org_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid() AND role = 'admin'
)
);Public Read-Only Table
Reference data, pricing tables — no auth needed
Fully public — anyone can read, no login required. All writes go through your server-side admin client using the service role key (never in the browser).
-- Completely public: anyone can read, nobody can write via client
CREATE POLICY "Public read"
ON pricing_plans FOR SELECT
USING (true);
-- No INSERT/UPDATE/DELETE policies = no client-side writes
-- All writes go through your server-side admin clientWhat AI Gets Wrong About RLS
If you're using Cursor, Lovable, Bolt, or any AI coding tool, these are the mistakes it generates 90% of the time.
Mistake 1: Enabling RLS Without Policies
Your notes table returns zero rows. You think the data is gone. It's not — it's just blocked. Always check whether policies exist immediately after enabling RLS.
-- What AI generates:
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- What it forgets:
-- Any policy at all.Fix
-- Quick check: do policies exist?
SELECT policyname, tablename, cmd, qual
FROM pg_policies
WHERE tablename = 'notes';
-- No rows = no policies = all access blockedMistake 2: auth.uid() Without an Index
Without an index on user_id, Postgres does a full table scan for every policy check. At 10k rows it's fine. At 500k rows your queries start timing out.
-- This works, but it's slow at scale
CREATE POLICY "Users see own rows"
ON posts FOR SELECT
USING (auth.uid() = user_id);Fix
-- Add this index immediately after creating the policy
CREATE INDEX ON posts(user_id);Mistake 3: Mixing Up USING and WITH CHECK
USING filters existing rows (SELECT, UPDATE, DELETE). WITH CHECK validates new/modified rows (INSERT, UPDATE). UPDATE needs both.
-- What AI often generates (wrong for INSERT):
CREATE POLICY "Users can insert"
ON posts FOR INSERT
USING (auth.uid() = user_id); -- USING on INSERT does nothingFix
-- Correct:
CREATE POLICY "Users can insert"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id); -- WITH CHECK is correct for INSERTRLS + Clerk JWT Sync
Using Clerk for auth and Supabase for the database? By default, Supabase's auth.uid() reads from a Supabase-issued JWT. Clerk issues its own JWT — which Supabase doesn't recognize. Your RLS policies will always get NULL from auth.uid().
Create a JWT Template in Clerk
Go to Clerk Dashboard → JWT Templates → New template → Select Supabase preset. Set the template to include:
{
"sub": "{{user.id}}",
"role": "authenticated",
"aud": "authenticated",
"iss": "https://your-clerk-domain.clerk.accounts.dev"
}Note the Signing Key and Issuer URL — you'll need both in Supabase.
Add Clerk as a JWT Provider in Supabase
In Supabase Dashboard → Authentication → Third-party Auth → Add provider → Clerk. Add your Clerk signing key from the JWT template. For older projects, go to Authentication → JWT Settings → Add new JWT Secret.
Configure Your Supabase Client
import { createClient } from '@supabase/supabase-js'
import { useAuth } from '@clerk/nextjs'
// In your component or API route:
const { getToken } = useAuth()
// Get a Supabase-compatible token from Clerk
const supabaseToken = await getToken({ template: 'supabase' })
// Create a Supabase client with the Clerk JWT
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
global: {
headers: {
Authorization: `Bearer ${supabaseToken}`,
},
},
}
)Update Your RLS Policies
With Clerk JWT sync, auth.uid() now returns the Clerk user ID. Make sure your user_id columns store Clerk user IDs (e.g., user_abc123), not Supabase UUIDs.
-- This now works correctly with Clerk
CREATE POLICY "Users see own rows"
ON posts FOR SELECT
USING (auth.uid()::text = user_id); -- Cast to text if user_id is TEXT type⚠️ Common mistake: Mixing Supabase UUIDs (from auth.users) with Clerk user IDs (strings like user_2abc...) in the same user_id column. Pick one auth system and be consistent.
Performance Anti-Patterns
These three patterns silently kill query performance. Your app feels fine in development with 100 rows. At 100k rows you're getting timeouts.
Anti-Pattern 1: auth.uid() Without an Index
The #1 performance killer. Without an index, Postgres does a full table scan.
-- Slow (no index):
USING (auth.uid() = user_id)
-- Fix: add the index
CREATE INDEX idx_your_table_user_id ON your_table(user_id);Anti-Pattern 2: Subqueries That Run Per Row
At scale this is brutal. Fix with a security definer function that Postgres can cache.
-- This runs a subquery for EVERY ROW in the table:
CREATE POLICY "Org members"
ON projects FOR SELECT
USING (
org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid())
);Fix
-- Create a cached lookup function
CREATE OR REPLACE FUNCTION get_user_org_ids()
RETURNS SETOF UUID
LANGUAGE SQL
SECURITY DEFINER
STABLE
SET search_path = public
AS $$
SELECT org_id FROM org_members WHERE user_id = auth.uid();
$$;
-- Now your policy is fast
CREATE POLICY "Org members"
ON projects FOR SELECT
USING (org_id IN (SELECT get_user_org_ids()));Anti-Pattern 3: Security Definer Without search_path
Without pinning search_path, a malicious user can create a fake public.auth schema and intercept calls.
-- Dangerous (no search_path):
CREATE OR REPLACE FUNCTION get_user_role()
RETURNS TEXT
LANGUAGE SQL
SECURITY DEFINER
AS $$
SELECT role FROM user_roles WHERE user_id = auth.uid();
$$;Fix
-- Safe:
CREATE OR REPLACE FUNCTION get_user_role()
RETURNS TEXT
LANGUAGE SQL
SECURITY DEFINER
STABLE
SET search_path = public
AS $$
SELECT role FROM user_roles WHERE user_id = auth.uid();
$$;Testing Your RLS Policies
Don't assume your policies work. Verify them in the SQL editor before shipping.
Test as a Specific User
-- In Supabase SQL Editor:
BEGIN;
-- Pretend to be a specific user
SET LOCAL role = authenticated;
SET LOCAL request.jwt.claims = '{"sub": "your-user-uuid-here", "role": "authenticated"}';
-- Now run your query — RLS will apply as if you're that user
SELECT * FROM posts;
-- Always rollback test queries
ROLLBACK;Test as Anonymous (Unauthenticated)
BEGIN;
SET LOCAL role = anon;
-- This should return zero rows if your policies are correct
SELECT * FROM posts;
ROLLBACK;Full Test Sequence for Each Pattern
Run these in sequence after creating any policy.
-- 1. Verify RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE tablename = 'your_table';
-- 2. List all policies on the table
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'your_table';
-- 3. Test SELECT as authenticated user
BEGIN;
SET LOCAL role = authenticated;
SET LOCAL request.jwt.claims = '{"sub": "test-user-id", "role": "authenticated"}';
SELECT count(*) FROM your_table;
ROLLBACK;
-- 4. Test INSERT as authenticated user
BEGIN;
SET LOCAL role = authenticated;
SET LOCAL request.jwt.claims = '{"sub": "test-user-id", "role": "authenticated"}';
INSERT INTO your_table (user_id, content) VALUES ('test-user-id', 'test');
ROLLBACK;RLS Debugging Playbook
Your policies aren't working. Here's how to find out why in under 5 minutes.
Is RLS Actually Enabled?
If rowsecurity = false — RLS is off. Data is fully public. If rowsecurity = true — check if policies exist.
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';Do Policies Exist?
No rows returned = no policies = all queries return empty (not an error, just empty).
SELECT * FROM pg_policies WHERE tablename = 'your_table';Is Your JWT Token Valid?
If this returns null, your JWT isn't being parsed. Common causes: wrong Supabase key, JWT not in Authorization header, or Clerk JWT template not configured.
SELECT auth.uid();Common Error Messages and Fixes
| Error | Cause | Fix |
|---|---|---|
new row violates row-level security policy | INSERT blocked by WITH CHECK | Check your INSERT policy's WITH CHECK clause — auth.uid() is probably null |
Query returns 0 rows (no error) | SELECT blocked by USING | Check if RLS is enabled and policies exist; check if auth.uid() matches your data |
permission denied for table | RLS enabled, anon role has no SELECT grant | Run: GRANT SELECT ON your_table TO anon; |
infinite recursion detected in policy | Policy references the same table it's on | Use a security definer function to break the recursion |
The Nuclear Option: Temporarily Bypass RLS
Verify data exists by connecting with service role (bypasses all RLS).
-- In Supabase Dashboard → SQL Editor, you're already using service role
SELECT * FROM your_table LIMIT 10;
-- If data shows here but not in your app → RLS is blocking it
-- If data doesn't show here → the data isn't being insertedRe-enable RLS Production Checklist
You disabled RLS for development. You're going to production. Here's every step, in order.
Step 1: Re-enable RLS on Every Table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
-- ... repeat for each tableBefore You Deploy, Verify All Of These
- ✓RLS enabled on every table with user data (pg_tables check)
- ✓At least one SELECT policy exists on every RLS-enabled table
- ✓INSERT policies use WITH CHECK, not USING
- ✓UPDATE policies have both USING and WITH CHECK
- ✓Every user_id column has an index (CREATE INDEX ON table(user_id))
- ✓Service role key is NOT in any frontend code (only anon key in browser)
- ✓Policies tested using SET LOCAL role in the SQL editor
- ✓For Clerk users: JWT template configured and auth.uid() returns correct user ID
- ✓Security definer functions have SET search_path = public
Quick Audit Query — Run Before Deploying
-- Find tables with RLS disabled
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;
-- Find RLS-enabled tables with no policies (will block all access)
SELECT t.tablename
FROM pg_tables t
LEFT JOIN pg_policies p ON t.tablename = p.tablename
WHERE t.schemaname = 'public'
AND t.rowsecurity = true
AND p.policyname IS NULL;Zero rows on both queries = you're good to ship.
Quick Reference: Most Common Patterns
-- ✅ User owns their rows
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id)
-- ✅ Public read
USING (true)
-- ✅ Authenticated only
WITH CHECK (auth.uid() IS NOT NULL)
-- ✅ Admin only
USING (auth.uid() IN (SELECT user_id FROM admins))
-- ✅ Check role from JWT
USING ((auth.jwt() ->> 'role') = 'admin')
-- ✅ Multi-tenant
USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()))
-- ❌ Wrong: USING on INSERT (does nothing)
-- ❌ Wrong: No index on user_id column
-- ❌ Wrong: auth.uid() returns null (Clerk JWT not configured)Related Resources
Auth Stack Guide →
Clerk vs Supabase vs NextAuth vs Firebase — with a decision tree and copy-paste setup.
External Logic
Official RLS Docs ↗
Full reference for policies, functions, and advanced Postgres security.
Security
Ship something real this week
Join the challenge to build a secure product with professional-grade database rules.
Join the ChallengePage last updated: March 2026. SQL tested against Supabase PostgreSQL 15.