Supabase Row Level Security 2026: Beginner's Guide

Learn RLS policies in Supabase with production-ready patterns, real error messages, and step-by-step examples for indie hackers.

TL;DR

Supabase Row Level Security (RLS) lets you enforce database access rules at the row level using PostgreSQL policies. Enable it per table, write policies using auth.uid() and custom claims, then test thoroughly. Common mistakes: forgetting USING vs WITH CHECK clauses, not testing as authenticated users, and overly permissive policies.

---

What is Row Level Security?

Row Level Security (RLS) is PostgreSQL's native feature that restricts which rows a user can access or modify. Supabase exposes this through its UI and API, making it accessible to developers who aren't SQL experts.

Why you need it:

  • Multi-tenant SaaS: User A shouldn't see User B's data
  • Compliance: GDPR-style data isolation
  • Defense in depth: Database-level enforcement beats application-level checks alone
  • How it works: Every query gets filtered through policies you define. If a user lacks permission, the row simply doesn't exist to them—they don't get an error, the row vanishes.

    ---

    Enable RLS on a Table

    First, navigate to your Supabase project dashboard or use the SQL editor.

    ```sql -- Enable RLS on the table ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;

    -- Verify it's enabled SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE tablename = 'posts'; ```

    Critical: Once RLS is enabled with *no policies defined*, the table becomes unreadable to all users except postgres role. Always write policies immediately after enabling.

    ---

    Core Policy Types

    Each policy has a direction and a condition:

    SELECT Policies (Read Access)

    ```sql -- User can only see their own posts CREATE POLICY "Users can read own posts" ON public.posts FOR SELECT USING (auth.uid() = user_id); ```

    INSERT Policies (Create)

    ```sql -- User can only create posts as themselves CREATE POLICY "Users can create own posts" ON public.posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```

    UPDATE Policies (Modify)

    This is where USING vs WITH CHECK matters:

    ```sql -- User can UPDATE their post (USING checks which rows) -- and can only set user_id to themselves (WITH CHECK validates new values) CREATE POLICY "Users can update own posts" ON public.posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ```

    USING clause: Filters which rows the user can see/modify (applies to old values) WITH CHECK clause: Validates new values after the update

    DELETE Policies

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

    ---

    Production-Ready Pattern: Teams & Ownership

    Here's a common real-world scenario:

    ```sql -- Team members can read/modify team projects CREATE POLICY "Team members can read projects" ON public.projects FOR SELECT USING ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_members.team_id = projects.team_id AND team_members.user_id = auth.uid() ) );

    CREATE POLICY "Team members can update projects" ON public.projects FOR UPDATE USING ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_members.team_id = projects.team_id AND team_members.user_id = auth.uid() AND team_members.role != 'viewer' ) ) WITH CHECK ( EXISTS ( SELECT 1 FROM public.team_members WHERE team_members.team_id = projects.team_id AND team_members.user_id = auth.uid() AND team_members.role != 'viewer' ) ); ```

    ---

    Real Error Messages You'll See

    Error 1: No Policies Defined

    ``` ERROR: new row violates row-level security policy "" for table "posts" ``` Fix: Add at least one SELECT policy before data becomes inaccessible.

    Error 2: auth.uid() Returns NULL

    ``` ERROR: relation "auth" does not exist LINE 1: SELECT ... WHERE user_id = auth.uid() ``` Fix: Ensure you're using authenticated requests. In development, manually pass the JWT token or use Supabase client libraries (v2.40.0+, verify in [official docs](https://supabase.com/docs/guides/auth)). Anonymous requests will have auth.uid() = NULL.

    Error 3: Permission Denied

    ``` ERROR: permission denied for schema "public" ``` Fix: User's JWT lacks necessary claims or the policy is too restrictive. Check token contents using jwt.io.

    ---

    Testing RLS Policies

    Always test as different user roles. The Supabase dashboard queries tables as postgres, so policies won't apply there.

    Use the JavaScript client:

    ```javascript // Sign in as User A const { data: userA, error } = await supabase.auth.signInWithPassword({ email: 'user.a@example.com', password: 'password123' });

    // Try to read posts (should only see own) const { data: posts } = await supabase .from('posts') .select('*');

    console.log('User A sees:', posts); // Should be filtered ```

    Debug tip: Check your JWT token at [jwt.io](https://jwt.io). The sub claim is your auth.uid(). If sub is missing, auth.uid() returns NULL and policies won't match.

    ---

    Common Pitfalls

    1. Forgetting WITH CHECK on INSERT/UPDATE: Your policy only checks old values, not new ones. User can insert user_id = someone_else_id. 2. Using = instead of @> with JSONB claims: Custom claims stored as JSONB require array operators: auth.jwt() ->> 'roles' for single values, auth.jwt() -> 'roles' @> '"admin"'::jsonb for arrays. 3. Not disabling RLS during migrations: Adding columns to a table with RLS enabled can be slow. Temporarily disable, migrate, re-enable. 4. Performance: EXISTS subqueries in policies can hit database load. Use denormalized columns (e.g., is_public BOOLEAN) when possible.

    ---

    Related Guides

  • [Authentication best practices with Supabase](/?guide=supabase-auth)
  • [Building multi-tenant applications](/?guide=multi-tenant-design)
  • ---

    Verification Checklist

  • [ ] RLS enabled on all customer-facing tables
  • [ ] SELECT, INSERT, UPDATE, DELETE policies written for each table
  • [ ] Policies tested as authenticated users (not postgres role)
  • [ ] WITH CHECK clause present on write operations
  • [ ] No overly permissive TRUE policies in production
  • [ ] JWT token contains expected auth.uid() and custom claims
  • [ ] Performance tested with realistic row counts
  • ---

    Resources

  • [Supabase RLS Official Guide](https://supabase.com/docs/guides/auth/row-level-security)
  • [PostgreSQL Row Level Security Docs](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
  • [Supabase Auth API Reference](https://supabase.com/docs/reference/auth/overview) — verify v2.40.0+ for latest auth improvements
  • ---

    What am I missing?

    This guide covers foundational RLS patterns for typical indie hacker use cases. What would help you most? Let me know in the comments:

  • Custom JWT claims for role-based access control?
  • RLS with real-time subscriptions?
  • Performance optimization patterns for complex policies?
  • Migration strategies for adding RLS to existing tables?
  • If you've hit production issues or discovered edge cases with RLS, share them—this guide needs your real-world 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