Supabase Row Level Security Guide 2026 - Indie Hackers

Production-ready RLS patterns for Supabase. Learn policies, common errors, and security best practices with real code examples.

Supabase Row Level Security: A Beginner's Guide for 2026

TL;DR: Row Level Security (RLS) is Supabase's permission layer that enforces database rules at query time. Enable it on tables, write policies using PostgreSQL, and test with auth.uid() and auth.jwt() claims. Common mistakes: forgetting to enable RLS, policies that block INSERT, and testing without proper auth context.

---

What is Row Level Security?

Row Level Security (RLS) is a PostgreSQL feature that Supabase exposes for frontend developers. Instead of trusting your client app to enforce permissions, RLS moves security to the database itself. Every query—read, insert, update, delete—is filtered by policies before data returns.

Why this matters: if someone hijacks your auth token or exploits your client-side logic, the database *still* won't let them access rows they shouldn't see.

Enabling RLS: The First Step

RLS is disabled by default on new tables (verify in your Supabase project's Table Editor). To enable it:

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

Once enabled, all queries fail unless you write explicit policies. This is intentional—default deny is more secure than default allow.

Core Concepts: auth.uid() and JWT Claims

Supabase authenticates users and makes identity available in PostgreSQL:

  • auth.uid() — returns the UUID of the currently authenticated user
  • auth.jwt() — returns the full JWT payload as a JSON object
  • current_user_id — custom identifier you can set in JWT claims
  • You reference these in policy expressions:

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

    Writing Your First Policy

    Here's a complete, production-ready example for a blogging app:

    ```sql -- Table setup CREATE TABLE posts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, title text NOT NULL, content text NOT NULL, published boolean DEFAULT false, created_at timestamptz DEFAULT now() );

    ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

    -- Policy 1: Users can view published posts OR their own posts CREATE POLICY "view_posts" ON posts FOR SELECT USING ( published = true OR user_id = auth.uid() );

    -- Policy 2: Users can only insert their own posts CREATE POLICY "insert_own_posts" ON posts FOR INSERT WITH CHECK (user_id = auth.uid());

    -- Policy 3: Users can only update their own posts CREATE POLICY "update_own_posts" ON posts FOR UPDATE USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());

    -- Policy 4: Users can only delete their own posts CREATE POLICY "delete_own_posts" ON posts FOR DELETE USING (user_id = auth.uid()); ```

    Key patterns:

  • Use USING for SELECT, DELETE (what can be seen/removed)
  • Use WITH CHECK for INSERT, UPDATE (what values are allowed)
  • Combine conditions with OR for complex rules
  • Real Errors You'll See

    When RLS blocks a query, Supabase returns one of these:

    Error 1: New rows violate RLS policy on INSERT

    ``` PgError: new row violates row-level security policy "insert_own_posts" for table "posts" ``` Cause: You're trying to insert with a user_id that doesn't match auth.uid(). Fix: Verify your JWT is valid and the user_id in your payload matches the database.

    Error 2: Permission denied for relation

    ``` PgError: permission denied for relation posts ``` Cause: RLS is enabled but NO policies exist, or policies are too restrictive. Fix: Check SELECT * FROM pg_policies WHERE tablename = 'posts'; to list policies. Ensure at least one SELECT policy exists for your use case.

    Error 3: Query returned no rows when one was expected

    ``` JavaScript: TypeError: Cannot read property 'id' of undefined ``` Cause: A policy silently filtered out all rows (not technically an RLS error, but caused by overly restrictive policies). Fix: Test with the correct user context; use supabase.auth.getUser() to confirm identity before querying.

    Testing Policies with Proper Auth Context

    This is critical: policies only work when you're authenticated. Test like this:

    ```javascript // ❌ Wrong: unauthenticated request const { data, error } = await supabase .from('posts') .select('*'); // Returns empty or permission denied

    // ✅ Correct: sign in first const { data: { user } } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'password123' });

    const { data, error } = await supabase .from('posts') .select('*'); // Now returns only posts where published=true OR user_id matches ```

    Advanced: Using JWT Claims

    For role-based access, add custom claims to your JWT:

    ```sql -- Policy using custom "role" claim CREATE POLICY "admin_can_moderate" ON posts FOR UPDATE USING (auth.jwt() ->> 'role' = 'admin') WITH CHECK (auth.jwt() ->> 'role' = 'admin'); ```

    Set custom claims via Supabase Auth with a trigger (see [auth customization guide](/?guide=supabase-auth-claims)).

    Common RLS Mistakes

    1. Forgetting to enable RLS — policies exist but don't enforce 2. No SELECT policy for INSERT — you can insert but never read it back 3. Overly permissive USING clauses — e.g., USING (true) defeats security 4. Testing without auth — policies silently fail; always sign in during tests 5. Mixing USING and WITH CHECK incorrectly — use both for UPDATE

    Performance Considerations

    RLS policies can impact query speed. Best practices:

  • Index columns used in policies (especially user_id)
  • Avoid expensive function calls in USING clauses
  • Use EXPLAIN ANALYZE to inspect query plans
  • Cache auth results rather than calling auth.uid() repeatedly
  • ```sql -- Good: indexed column CREATE INDEX idx_posts_user_id ON posts(user_id);

    -- Bad: function call in policy CREATE POLICY "slow_policy" ON posts FOR SELECT USING (user_id = (SELECT id FROM users WHERE email = current_user)); ```

    Debugging: View All Policies

    When things break, inspect your policies:

    ```sql SELECT tablename, policyname, qual, with_check FROM pg_policies WHERE tablename = 'posts' ORDER BY tablename, policyname; ```

    Run this in your Supabase SQL editor to see every policy on every table.

    Resources

  • [Official Supabase RLS Docs](https://supabase.com/docs/guides/auth/row-level-security)
  • [PostgreSQL RLS Reference](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
  • [Supabase Auth Best Practices](/?guide=supabase-auth-best-practices)
  • ---

    What am I missing?

    RLS is nuanced—policies for multi-tenant apps, team-based access, or hierarchical permissions get complex fast. Drop corrections and advanced patterns in the comments. Did I oversimplify a permission model? Miss a version-specific feature? Let's crowdsource the definitive guide.

    🔥 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