Supabase Row Level Security: Beginner's Guide 2026

Master Supabase RLS policies with production patterns. Real errors, exact code, and critical gotchas every indie hacker needs.

TL;DR

Supabase Row Level Security (RLS) enforces data access at the database level using PostgreSQL policies. Enable it per table, write policies matching your auth scheme, and test with service role keys vs. authenticated tokens. Most beginners skip the USING clause or forget auth.uid() context—both cause silent failures.

---

What is Row Level Security?

RLS is a PostgreSQL feature that Supabase exposes through its dashboard and client libraries. Instead of filtering data in your application code (vulnerable to logic errors), you define rules at the database layer:

  • Only authenticated users see their own data
  • Admin users see everything
  • Public tables allow anonymous reads but no writes
  • Without RLS, any user with your anon key can query any row. With RLS enabled, PostgreSQL enforces policies before returning results.

    ---

    Enable RLS on a Table

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

    That's it. Now:

  • All reads blocked (without policies)
  • All writes blocked (without policies)
  • Service role can still access (bypass mode)
  • Critical: Enable RLS *before* adding policies, or your app breaks immediately.

    ---

    Core RLS Concepts

    The Authentication Context

    Every request includes metadata:

    ```sql auth.uid() -- Current user's UUID auth.jwt() -- Full JWT claims object auth.role() -- 'authenticated' or 'anon' current_setting('request.jwt.claims') -- Raw claims ```

    You reference these in policies:

    ```sql SELECT * FROM profiles WHERE id = auth.uid(); -- Only see your own row ```

    Policy Structure

    Each policy has:

  • Name: Unique identifier
  • Command: SELECT, INSERT, UPDATE, DELETE, or ALL
  • Role: Which auth.role() it applies to
  • USING clause: Filters existing rows (SELECT, UPDATE, DELETE)
  • WITH CHECK clause: Validates new/updated data (INSERT, UPDATE)
  • ---

    Real-World Patterns

    Pattern 1: User Owns Their Profile

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

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

    The WITH CHECK prevents users from changing id to someone else's UUID.

    Pattern 2: Public Read, Authenticated Write

    ```sql CREATE POLICY "Anyone can read posts" ON posts FOR SELECT USING (true);

    CREATE POLICY "Users can insert own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); ```

    Pattern 3: Admin Override

    ```sql CREATE POLICY "Admins see everything" ON posts FOR ALL USING ( auth.uid() IN ( SELECT id FROM profiles WHERE role = 'admin' ) ); ```

    Warning: This policy queries another table on every request. Cache admin IDs in a custom claim instead ([authentication patterns](/?guide=supabase-auth)).

    Pattern 4: Team-Based Access

    ```sql CREATE POLICY "Users access their team's projects" ON projects FOR SELECT USING ( team_id IN ( SELECT team_id FROM team_members WHERE user_id = auth.uid() ) ); ```

    ---

    Common Errors & Solutions

    Error 1: Policy Returns No Rows

    ``` ERROR: new row violates row-level security policy for table "profiles" DETAIL: Failing row contains (550e8400-e29b-41d4-a716-446655440000, john@example.com, ...). ```

    Cause: WITH CHECK clause rejected the insert. Usually:

  • User ID mismatch: auth.uid() doesn't match user_id field
  • Missing ON CONFLICT handling
  • Fix: Verify field names and order of comparisons.

    ```javascript const { error } = await supabase .from('profiles') .insert({ id: auth.uid(), email: 'user@example.com' }); // id must match auth.uid() ```

    Error 2: Permission Denied

    ``` ERROR: permission denied for schema public ```

    Cause: Using anon key on a table with no SELECT policy.

    Fix: Add a policy or use service role key (only in backend).

    Error 3: Silent Empty Results

    ```javascript const { data } = await supabase .from('posts') .select(); // Returns [] even though posts exist ```

    Cause: Missing SELECT policy. No error thrown—just empty results. This is the worst one because it "works" locally with service role.

    Fix: Always test with anon key in dev.

    ---

    Testing RLS Properly

    Wrong Approach (Local Development)

    ```javascript const supabase = createClient(url, SERVICE_ROLE_KEY); // Service role BYPASSES RLS—tests pass but production fails ```

    Right Approach

    ```javascript // Test as anonymous user const anonClient = createClient(url, ANON_KEY); const { data: anonPosts } = await anonClient .from('posts') .select();

    // Test as authenticated user const authClient = createClient(url, ANON_KEY); await authClient.auth.signInWithPassword({ email, password }); const { data: authPosts } = await authClient .from('posts') .select(); ```

    Supabase version: These patterns work on v1.51+. [Verify in official docs](https://supabase.com/docs/guides/auth/row-level-security).

    ---

    Performance Considerations

    Avoid This

    ```sql -- Queries team_members table on EVERY request CREATE POLICY "Access team data" ON documents FOR SELECT USING ( team_id IN ( SELECT team_id FROM team_members WHERE user_id = auth.uid() ) ); ```

    Do This Instead

    Store team info in JWT custom claims:

    ```sql CREATE POLICY "Access team data" ON documents FOR SELECT USING ( team_id = (auth.jwt() ->> 'team_id')::uuid ); ```

    Update claims during login (in your backend).

    ---

    Quick Checklist

  • [ ] ALTER TABLE tablename ENABLE ROW LEVEL SECURITY;
  • [ ] At least one SELECT policy per table (or public data leaks)
  • [ ] Test with anon key, not service role
  • [ ] Use auth.uid() for user context
  • [ ] Add WITH CHECK clause for INSERT/UPDATE
  • [ ] Index columns used in policies (e.g., team_id)
  • [ ] Review policies in dashboard: Auth → Policies tab
  • ---

    Resources

  • [Supabase RLS Official Docs](https://supabase.com/docs/guides/auth/row-level-security)
  • [PostgreSQL Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
  • [JWT Custom Claims Setup](/?guide=supabase-jwt-claims)
  • [Database Design for Multi-Tenant Apps](/?guide=multitenant-databases)
  • ---

    What am I missing?

    Comments below: Did I gloss over a critical error you've hit? Better patterns for team access? Gotchas with real-time subscriptions and RLS? I'm reading every response.

    🔥 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