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