Supabase Row Level Security: Beginner Guide 2026

Master RLS in Supabase with production-ready patterns, real error messages, and exact setup steps for securing your data at the database level.

TL;DR

Supabase Row Level Security (RLS) enforces data access rules directly in PostgreSQL. Enable RLS on tables, create policies matching your auth model, and test thoroughly—common mistakes include forgetting to enable RLS globally, mixing up auth.uid() with custom claims, and policies that silently return zero rows instead of throwing errors.

---

What is Row Level Security?

Row Level Security is a PostgreSQL feature that restricts which rows a user can access based on policies you define. Supabase wraps this with its auth system, giving you:

  • Database-level enforcement - No client-side tricks bypass your rules
  • Performance - Filtering happens before data leaves the server
  • Declarative security - Define rules once, use everywhere
  • Without RLS, anyone with valid JWT tokens can query any row. With RLS, a user sees only rows their policy allows.

    ---

    Prerequisites

  • Supabase project (free tier works for learning)
  • PostgreSQL 9.5+ (Supabase uses 15.x or higher; verify in your project settings)
  • Basic understanding of [authentication in Supabase](/?guide=supabase-auth)
  • Familiarity with SQL basics
  • ---

    Step 1: Enable RLS on Your Table

    Create a test table first:

    ```sql CREATE TABLE posts ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL ); ```

    Now enable RLS:

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

    Critical: Without this, policies won't take effect. Forgetting this is the #1 beginner mistake.

    ---

    Step 2: Create Your First Policy

    Users should see only their own posts:

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

    Breakdown:

  • Policy name - Required, descriptive string
  • FOR SELECT - Applies to read operations (also FOR INSERT, UPDATE, DELETE, or ALL)
  • USING clause - Returns true/false for each row; true = user can access
  • auth.uid() - Built-in Supabase function returning current user's UUID
  • Users can also insert their own posts:

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

    Note: INSERT and UPDATE use WITH CHECK instead of USING.

    Users can update their own posts:

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

    Users can delete their own posts:

    ```sql CREATE POLICY "Users can delete own posts" ON posts FOR DELETE USING (auth.uid() = user_id); ```

    ---

    Step 3: Allow Public Read Access

    Let anyone read published posts:

    ```sql CREATE POLICY "Anyone can view published posts" ON posts FOR SELECT USING (is_published = true);

    -- Don't forget to add the column first: ALTER TABLE posts ADD COLUMN is_published BOOLEAN DEFAULT false; ```

    Policies are additive—a user needs to match *at least one* SELECT policy to read a row.

    ---

    Common Error Messages You'll See

    Error 1: Policy Not Enforced

    ``` PolicyViolationException: new row violates row-level security policy ```

    Cause: RLS is enabled, but your policy's WITH CHECK condition failed on INSERT/UPDATE.

    Example: ```javascript const { data, error } = await supabase .from('posts') .insert([{ title: 'Test', user_id: 'wrong-uuid' }]); // Error because INSERT policy checks auth.uid() = user_id ```

    Fix: Always use the current user's ID from supabase.auth.getSession().

    Error 2: Silent Empty Results

    ``` // No error thrown, but results.data = [] const { data } = await supabase.from('posts').select(); console.log(data); // [] ```

    Cause: User matched no SELECT policies; RLS silently returns empty set. This is *not* an error—it's correct behavior but confusing to debug.

    Fix: Check your policy logic. Does the current user's ID match any row's user_id? Is is_published actually true? Add logging:

    ```sql -- Debug: See all policies on a table SELECT schemaname, tablename, policyname, qual, with_check FROM pg_policies WHERE tablename = 'posts'; ```

    Error 3: Missing auth.uid()

    ``` function auth.uid() does not exist ```

    Cause: Using Supabase without the auth extension or calling from wrong context.

    Fix: Verify in SQL editor that SELECT auth.uid(); returns a UUID. If it returns NULL, check that your JWT token is valid in the current session.

    ---

    Advanced Pattern: Admin Bypass

    Admins should see all posts. Add an is_admin flag to your users table:

    ```sql ALTER TABLE auth.users ADD COLUMN is_admin BOOLEAN DEFAULT false;

    CREATE POLICY "Admins can view all posts" ON posts FOR SELECT USING ( EXISTS ( SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.is_admin = true ) ); ```

    Warning: Never trust client-provided claims for security-critical checks. Always verify in the database.

    ---

    Testing Your Policies

    1. Via SQL Editor - Use "Change User" dropdown to impersonate users 2. Via JavaScript Client - Create multiple auth sessions and test 3. Via curl with JWT tokens - Manually craft bearer tokens (advanced)

    Production-ready test pattern:

    ```javascript // Test as User A const { data: userA } = await supabase.auth.signUp({ email: 'a@test.com' }); await supabase .from('posts') .insert([{ title: 'User A Post', user_id: userA.user.id }]);

    // Sign in as User B await supabase.auth.signOut(); await supabase.auth.signInWithPassword({ email: 'b@test.com' });

    // User B should see zero results const { data: posts } = await supabase.from('posts').select(); console.assert(posts.length === 0, 'User B leaked User A data!'); ```

    ---

    Performance Considerations

  • RLS policies are evaluated *per row*, not per query. A query scanning 10k rows evaluates each policy 10k times.
  • Use indexes on columns in policy conditions:
  • ```sql CREATE INDEX posts_user_id_idx ON posts(user_id); CREATE INDEX posts_published_idx ON posts(is_published) WHERE is_published = true; ```
  • For complex checks, consider caching user roles in a separate indexed table rather than recursive CTEs.
  • ---

    References

  • [Official Supabase RLS Docs](https://supabase.com/docs/guides/auth/row-level-security)
  • [PostgreSQL RLS Specification](https://www.postgresql.org/docs/15/ddl-rowsecurity.html)
  • Related guide: [Supabase JWT and custom claims](/?guide=supabase-jwt)
  • ---

    What am I missing?

    Have you hit RLS gotchas we didn't cover? Use cases for multi-tenant architectures? Better patterns for organization-based access? Drop your corrections and experiences in the comments—this guide improves with reader feedback.

    🔥 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