Supabase Row Level Security Guide 2026

Master RLS policies in Supabase. Real error patterns, production code, and security best practices for indie hackers.

TL;DR

Supabase Row Level Security (RLS) restricts database access at the row level using PostgreSQL policies. Enable it per table, write policies that reference auth.uid(), and test thoroughly—misconfigured RLS is a common source of security leaks and silent failures in production.

---

What Is Row Level Security?

Row Level Security (RLS) is a PostgreSQL feature that Supabase exposes directly. Instead of controlling access at the application layer, RLS moves the security boundary to the database itself. Each row has invisible rules: "only user X can read this" or "only admins can update this."

This matters because:

  • No application bypass: Even if your API logic is compromised, the database enforces rules
  • Scalable: One policy definition protects all queries
  • Built-in: No external auth service calls per query
  • ---

    Enable RLS on a Table

    First, create a table (or alter an existing one):

    ```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 NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP );

    -- Enable RLS ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ```

    When RLS is enabled with no policies, all queries return zero rows. This is intentional and a common first error.

    ---

    Common Error: "SELECT 0 rows"

    Console Error: ``` Error: No rows returned in a non-0 result expected ```

    Root Cause: RLS enabled, but no matching policy exists.

    Fix: Write a policy:

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

    Now SELECT * FROM posts WHERE user_id = <current_user_id> returns rows.

    ---

    Policy Syntax Breakdown

    A policy has five parts:

    ```sql CREATE POLICY "policy_name" ON table_name FOR operation -- SELECT, INSERT, UPDATE, DELETE, or ALL USING (expression) -- For SELECT/UPDATE/DELETE: row must pass this WITH CHECK (expression); -- For INSERT/UPDATE: new row must pass this ```

    Example: Multi-operation policy

    ```sql -- Users insert their own posts CREATE POLICY "Users manage own posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id);

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

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

    ---

    Real-World Policy Examples

    Public read, private write:

    ```sql CREATE POLICY "Public posts are readable" ON posts FOR SELECT USING (is_published = true);

    CREATE POLICY "Users read own drafts" ON posts FOR SELECT USING (is_published = false AND auth.uid() = user_id); ```

    Team access with role check:

    ```sql CREATE TABLE team_documents ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, team_id BIGINT NOT NULL, content TEXT, created_by UUID NOT NULL REFERENCES auth.users(id) );

    ALTER TABLE team_documents ENABLE ROW LEVEL SECURITY;

    CREATE TABLE team_members ( id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, team_id BIGINT NOT NULL, user_id UUID NOT NULL REFERENCES auth.users(id), role TEXT CHECK (role IN ('viewer', 'editor', 'admin')), UNIQUE(team_id, user_id) );

    CREATE POLICY "Team members access docs" ON team_documents FOR SELECT USING ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() ) );

    CREATE POLICY "Only editors can update" ON team_documents FOR UPDATE USING ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() AND team_members.role IN ('editor', 'admin') ) ) WITH CHECK ( EXISTS ( SELECT 1 FROM team_members WHERE team_members.team_id = team_documents.team_id AND team_members.user_id = auth.uid() AND team_members.role IN ('editor', 'admin') ) ); ```

    ---

    Testing RLS Policies

    Error: "permission denied for schema public"

    This means you're signed out or the policy check failed.

    Test with Supabase client (verify in official docs for latest SDK version):

    ```javascript // As authenticated user const { data, error } = await supabase .from('posts') .select('*');

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

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

    Your INSERT/UPDATE data doesn't pass the WITH CHECK expression:

    ```sql -- This fails if you try to INSERT with a different user_id INSERT INTO posts (user_id, title, content) VALUES ('different-uuid', 'Title', 'Content'); -- Error: new row violates row-level security policy "Users manage own posts" ```

    ---

    Performance Considerations

  • Index on auth.uid(): If policies reference auth.uid() often, index the foreign key column:
  • ```sql CREATE INDEX idx_posts_user_id ON posts(user_id); ```
  • Avoid N+1 in policies: Subqueries in EXISTS are okay, but avoid loops. Use EXISTS instead of COUNT(*).
  • Monitor policy evaluation: In production, policies add microseconds. Use slow query logs to catch expensive policies.
  • ---

    Bypassable Only by Service Role

    RLS policies apply to all authenticated users. To bypass RLS (for admin scripts), use the service role key with the Supabase client:

    ```javascript const adminClient = createClient(url, SERVICE_ROLE_KEY); // This bypasses RLS const { data } = await adminClient.from('posts').select('*'); ```

    Never expose SERVICE_ROLE_KEY in client code.

    ---

    Debugging Checklist

    1. Is RLS enabled? Check: ALTER TABLE table_name ENABLE ROW LEVEL SECURITY; 2. Does a policy exist for your operation? SELECT * FROM pg_policies WHERE tablename = 'table_name'; 3. Is auth.uid() set? Sign in first—auth.uid() is NULL for anonymous users. 4. Does the policy expression pass for your data? Test manually: SELECT auth.uid() = user_id; 5. Are you using the anon key (respects RLS) or service role key (bypasses RLS)?

    ---

    Recommended 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)
  • Related: [Authentication patterns](/?guide=supabase-auth)
  • Related: [Database optimization](/?guide=postgres-performance)
  • ---

    What am I missing?

    Did I skip something critical? Have you hit an RLS error pattern not covered here? Drop corrections, questions, and real-world edge cases in the comments. Indie hackers implementing their first security layer often discover gaps—let's fix them together.

    🔥 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