Supabase Row Level Security: Beginner's Guide 2026

Master RLS policies in Supabase to control database access at the row level. Production-ready patterns, real errors, and security best practices.

TL;DR

Supabase Row Level Security (RLS) enforces access control at the database row level using PostgreSQL policies. Enable RLS on tables, create policies tied to auth.uid(), and test thoroughly—misconfigured RLS is a common security vulnerability. This guide covers setup, common errors, and production patterns.

---

What is Row Level Security?

Row Level Security is a PostgreSQL feature that Supabase exposes through its dashboard and client libraries. Instead of trusting your application code to filter data, RLS pushes access control to the database itself. Every query—regardless of origin—respects these policies.

Why it matters:

  • Prevents accidental data leaks from application bugs
  • Protects against unauthorized direct API access
  • Auditable at the database layer
  • Essential for multi-tenant applications
  • ---

    Enable RLS on a Table

    First, enable RLS on your table. This is the critical first step:

    ```sql ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; ```

    Important: Once RLS is enabled with *no policies*, all queries return zero rows by default. You must create at least one policy or queries will fail silently.

    ---

    Creating Your First Policy

    Pattern 1: Users Can Only See Their Own Row

    This is the most common pattern for user profile data:

    ```sql CREATE POLICY "Users can view their own profile" ON profiles FOR SELECT USING (auth.uid() = user_id);

    CREATE POLICY "Users can update their own profile" ON profiles FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ```

    Explanation:

  • FOR SELECT: Policy applies to read operations
  • USING: The condition evaluated against the database row. Must return true to include the row
  • WITH CHECK: For UPDATE/INSERT, the condition checked before the operation completes
  • auth.uid(): Built-in Supabase function returning the current user's UUID
  • Pattern 2: Public Read, Authenticated Write

    For content like blog posts:

    ```sql -- Anyone can read published posts CREATE POLICY "Public posts are viewable to all" ON posts FOR SELECT USING (is_published = true);

    -- Only the author can update their post CREATE POLICY "Authors can update their own posts" ON posts FOR UPDATE USING (auth.uid() = author_id) WITH CHECK (auth.uid() = author_id);

    -- Only authenticated users can create CREATE POLICY "Authenticated users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id AND auth.role() = 'authenticated'); ```

    Pattern 3: Role-Based Access

    For admin functionality:

    ```sql CREATE POLICY "Admins can see all users" ON profiles FOR SELECT USING ( auth.uid() IN ( SELECT user_id FROM admin_users WHERE role = 'admin' ) ); ```

    ---

    Common Errors and Solutions

    Error 1: No Rows Returned After Enabling RLS

    ``` Error: 0 rows returned from query ```

    Cause: RLS is enabled but no SELECT policies exist.

    Fix: Create at least one SELECT policy, or temporarily disable RLS for testing:

    ```sql ALTER TABLE profiles DISABLE ROW LEVEL SECURITY; ```

    Error 2: "new row violates row-level security policy"

    ``` Error: PostgreSQL error: new row violates row-level security policy for table "posts" ```

    Cause: Your INSERT/UPDATE policy's WITH CHECK condition failed.

    Example problem: ```sql -- User tries to create post but author_id doesn't match auth.uid() const { error } = await supabase .from('posts') .insert([{ title: 'Test', author_id: 'someone-else-uuid' }]); ```

    Fix: Ensure WITH CHECK logic matches your application:

    ```sql CREATE POLICY "Users create posts as themselves" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); ```

    Error 3: "Permission denied for schema public"

    ``` Error: permission denied for schema public ```

    Cause: User role lacks basic table access. RLS policies are secondary—base permissions matter first.

    Fix: Verify in Supabase dashboard that your authenticated role has table privileges. In SQL:

    ```sql GRANT SELECT, INSERT, UPDATE ON profiles TO authenticated; ```

    ---

    Production-Ready Pattern

    Here's a complete, testable example combining multiple patterns:

    ```sql -- Table with timestamps and ownership CREATE TABLE documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner_id UUID NOT NULL REFERENCES auth.users(id), title TEXT NOT NULL, is_shared BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT now() );

    -- Enable RLS ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

    -- Owners see their own docs CREATE POLICY "Owners see their documents" ON documents FOR SELECT USING (auth.uid() = owner_id);

    -- Shared docs visible to all authenticated users CREATE POLICY "Authenticated users see shared documents" ON documents FOR SELECT USING (is_shared = true AND auth.role() = 'authenticated');

    -- Only owners can insert CREATE POLICY "Owners can create documents" ON documents FOR INSERT WITH CHECK (auth.uid() = owner_id);

    -- Only owners can update their docs CREATE POLICY "Owners can update documents" ON documents FOR UPDATE USING (auth.uid() = owner_id) WITH CHECK (auth.uid() = owner_id AND is_shared IN (true, false));

    -- Only owners can delete CREATE POLICY "Owners can delete documents" ON documents FOR DELETE USING (auth.uid() = owner_id); ```

    ---

    Testing RLS Policies

    Always test policies before deploying. Use Supabase's SQL editor with the authenticated user context:

    ```sql -- Test as authenticated user SET LOCAL request.jwt.claims = '{"sub": "your-test-uuid"}'; SELECT * FROM documents; -- Should only return owned docs ```

    Or test via client:

    ```javascript const { data, error } = await supabase .from('documents') .select('*');

    if (error) console.error('RLS blocked query:', error); ```

    ---

    Key Gotchas

    1. RLS doesn't apply to service role: Queries using service_role key bypass RLS entirely. Keep this key secret. 2. Policies are additive: If multiple policies match, the query succeeds if *any* return true (OR logic). 3. NULL comparisons: auth.uid() = user_id returns false if user_id is NULL. Use COALESCE if needed. 4. Performance: Complex policies with subqueries can slow down queries. Index appropriately—[verify in official docs](https://supabase.com/docs/guides/auth/row-level-security) for performance recommendations.

    ---

    Related Resources

  • [Supabase RLS Official Docs](https://supabase.com/docs/guides/auth/row-level-security)
  • [PostgreSQL Policy Documentation](https://www.postgresql.org/docs/current/sql-createpolicy.html)
  • [Security Best Practices](/?guide=database-security)
  • [Multi-Tenant Architecture](/?guide=multi-tenant-design)
  • ---

    What am I missing?

    RLS patterns vary widely by use case. What policies are you building? Share your patterns, gotchas you've hit, or scenarios this guide should cover in the comments. Did a particular error message take you hours to debug? Let me know—I'll add it here.

    🔥 0d
    LIVE
    ↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising •↑ Resend loved by devs •↓ PlanetScale rage spiking •⚠ Vercel pricing complaints •↑ Railway gaining fast •↑ Supabase happiness rising
    DEVELOPER PAIN RADAR // Loading...

    Developers complain.
    Opportunities appear.

    We track what developers are struggling with today — and what opportunities that creates.

    —
    guides today
    —
    avg happiness
    🔥 Pain
    📖 Guides
    🔭 Explore
    👤 Mine
    🔥 Pain Radar — rage scores today
    ↗ share
    💡 Opportunity Feed — pain = market gap
    📈 Tool Momentum
    all scores →
    📖 Latest Guide
    all guides →
    📖 All Guides
    📊 Tool Scores
    + Submit
    📰 Hacker News
    ➕ Submit a Tool
    ← back