Following the path? See every step →

Supabase RLS: The Copy-Paste Guide for Vibe Coders

5 working SQL patterns, Clerk setup, AI gotchas, and a debugging playbook. No fluff — just SQL that works.

Quick answer: ALTER TABLE tablename DISABLE ROW LEVEL SECURITY; — but only in development. Read the re-enable checklist first.

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.

01

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.

SQL
-- 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);
02

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.

SQL
-- 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);
03

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.

SQL
-- 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).

04

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.

SQL
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'
  )
);
05

Service Role Bypass

Server-side admin operations, migrations, cron jobs

RLS applies to requests made with the public (anon / publishable) key. The service_role (secret) key bypasses all policies — use it server-side for admin operations, data migrations, and background jobs. Never expose it to the browser.

SQL
-- The service role bypasses ALL RLS policies automatically.
-- No special policy needed — just use the service role client server-side.

-- Server-side (Node.js / Next.js API route / Edge function):
import { createClient } from '@supabase/supabase-js'

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!  // Never in NEXT_PUBLIC_*
)

// This query ignores all RLS — sees every row
const { data } = await supabaseAdmin.from('users').select('*')

The service role key has full read/write/delete access to every table. Store it in server-only environment variables (no NEXT_PUBLIC_ prefix). Use it for admin dashboards, cron jobs, webhooks, and data migrations — never in client-side code.

-- To verify the anon role CAN'T bypass RLS, test in SQL editor:
BEGIN;
SET LOCAL role = anon;
SELECT * FROM your_table;  -- Should return only policy-allowed rows
ROLLBACK;

-- The SQL editor normally runs as the postgres role, which bypasses RLS:
SELECT * FROM your_table;  -- Returns everything

What 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 blocked
⚠️

Mistake 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);  -- Postgres rejects this: INSERT policies only take WITH CHECK

Fix

-- Correct:
CREATE POLICY "Users can insert"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);  -- WITH CHECK is correct for INSERT

RLS + Clerk

Using Clerk for auth and Supabase for the database? Supabase has to be told to trust Clerk's session tokens, and your policies have to read the Clerk user ID from the token. Clerk user IDs are strings like user_2abc..., not UUIDs, so auth.uid() does not work with them.

Older tutorials use a Clerk "Supabase JWT template" and share the Supabase JWT secret with Clerk. That method is deprecated. Use the native integration below; it needs no shared secret and no template.

1. Turn on the integration (both dashboards)

In the Clerk Dashboard, open the Supabase integration setup and activate it; Clerk shows you your Clerk domain. In the Supabase Dashboard, go to Authentication → Sign In / Up → Third-Party Auth, add Clerk, and paste that domain.

2. Pass the Clerk session token to Supabase

// Server (Next.js App Router)
import { createClient } from '@supabase/supabase-js'
import { auth } from '@clerk/nextjs/server'

export function createClerkSupabaseClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      // Supabase calls this for every request
      async accessToken() {
        return (await auth()).getToken()
      },
    }
  )
}

3. Store the Clerk ID as text and read it from the token

-- user_id is TEXT and fills itself in from the Clerk token
ALTER TABLE posts
  ADD COLUMN user_id TEXT NOT NULL DEFAULT (auth.jwt()->>'sub');

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users see own rows" ON posts
FOR SELECT TO authenticated
USING ((SELECT auth.jwt()->>'sub') = user_id);

CREATE POLICY "Users insert own rows" ON posts
FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.jwt()->>'sub') = user_id);

Common mistakes: using auth.uid() in a policy (it tries to read the Clerk ID as a UUID and errors), making user_id a UUID column, or mixing Supabase Auth users and Clerk users in the same column. Pick one auth system per project.

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 1: add the index
CREATE INDEX idx_your_table_user_id ON your_table(user_id);

-- Fix 2: wrap auth.uid() in a SELECT so Postgres evaluates it
-- once per query instead of once per row
USING ((SELECT auth.uid()) = 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

A security definer function runs with its owner's privileges. If search_path isn't pinned, a caller can put their own objects earlier in the path and have the function run them with those elevated privileges.

-- 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 (with Clerk) the third-party auth integration isn't set up — and with Clerk, check auth.jwt()->>'sub' instead, because auth.uid() only works for Supabase Auth users.

SELECT auth.uid();

Common Error Messages and Fixes

ErrorCauseFix
new row violates row-level security policyINSERT blocked by WITH CHECKCheck your INSERT policy's WITH CHECK clause — auth.uid() is probably null
Query returns 0 rows (no error)SELECT blocked by USINGCheck if RLS is enabled and policies exist; check if auth.uid() matches your data
permission denied for tableRLS enabled, anon role has no SELECT grantRun: GRANT SELECT ON your_table TO anon;
infinite recursion detected in policyPolicy references the same table it's onUse 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).

-- The Supabase SQL Editor runs as the postgres role, which bypasses RLS

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 inserted

Re-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 table

Before 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: third-party auth integration enabled and policies use auth.jwt()->>'sub', not auth.uid()
  • ✓Security definer functions pin search_path (SET search_path = public or '')

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() -> 'app_metadata' ->> 'role') = 'admin')  -- app_metadata, never user_metadata

-- ✅ Multi-tenant
USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()))

-- ❌ Wrong: USING on INSERT (Postgres rejects it; use WITH CHECK)
-- ❌ Wrong: No index on user_id column
-- ❌ Wrong: auth.uid() with Clerk (use auth.jwt()->>'sub')

Security

Ship something real this week

Follow the path to build a secure product with professional-grade database rules.

Follow the path

Page last reviewed: October 2026.