Supabase Row Level Security: Beginner's Guide 2026

Master RLS policies in Supabase with production patterns, real errors, and exact SQL. Secure your PostgreSQL data at the database layer.

TL;DR

Row Level Security (RLS) in Supabase enforces data access at the database layer using PostgreSQL policies. Enable RLS on tables, write policies targeting auth.uid(), and test thoroughly—misconfigured policies cause silent failures or "permission denied" errors. This guide covers setup, real errors, and production patterns.

---

What is Row Level Security?

Row Level Security is a PostgreSQL feature that restricts which rows users can access based on policies you define. Supabase wraps this with authentication context, letting you write policies like: "only show posts where user_id matches the authenticated user."

Without RLS, your application's auth logic is the only barrier. A single bug exposes all data. With RLS, the *database itself* enforces access—even if your API is compromised.

---

Setup: Enable RLS on a Table

Step 1: Create a table

```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, created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); ```

Step 2: Enable RLS

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

Critical: Without this, policies won't apply. Verify in your table editor in Supabase dashboard—you'll see a lock icon next to the table name once enabled.

Step 3: Create a policy

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

Breakdown:

  • FOR SELECT – applies to read operations
  • USING (condition) – rows matching this condition are visible
  • auth.uid() – Supabase function returning the authenticated user's UUID
  • Step 4: Allow inserts

    ```sql CREATE POLICY "Users can create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); ```

    WITH CHECK ensures the inserted row satisfies the condition. Users can't set user_id to someone else's UUID.

    Step 5: Allow updates and deletes

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

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

    ---

    Real Errors You'll Hit

    Error 1: Silent Empty Results

    Symptom: Query returns [] but you expected data.

    ``` -- In browser console after querying posts const { data } = await supabase .from('posts') .select('*') console.log(data); // [] ```

    Cause: RLS policy denies access. The client doesn't error—it silently filters.

    Fix: Check that your policy condition is correct. Use Supabase SQL editor to test:

    ```sql SELECT auth.uid(); -- Should return a UUID, not NULL SELECT * FROM posts WHERE auth.uid() = user_id; ```

    Error 2: Permission Denied on Insert

    ``` Error: new row violates row-level security policy "Users can create posts" on table "posts" ```

    Cause: Your WITH CHECK clause is rejecting the insert. Most common: forgetting to set user_id in your INSERT.

    Production pattern:

    ```javascript const { data, error } = await supabase .from('posts') .insert([ { title: 'My post', content: 'Content here', user_id: (await supabase.auth.getUser()).data.user.id } ]) .select();

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

    Error 3: NULL Auth Context

    ``` Error: permission denied for schema public ```

    Cause: auth.uid() returns NULL (user not authenticated), and your policy doesn't handle anonymous access.

    Fix: If you want anonymous reads, create a separate policy:

    ```sql CREATE POLICY "Anonymous can view published posts" ON posts FOR SELECT USING (published = true); ```

    ---

    Production-Ready Patterns

    Pattern 1: Multi-tenant with organization roles

    ```sql -- Assume auth.jwt() contains org_id claim CREATE POLICY "Users see org data they belong to" ON documents FOR SELECT USING ( org_id = (auth.jwt() ->> 'org_id')::uuid AND EXISTS ( SELECT 1 FROM org_members WHERE org_id = documents.org_id AND user_id = auth.uid() ) ); ```

    Pattern 2: Public + private posts

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

    CREATE POLICY "Users see their own draft posts" ON posts FOR SELECT USING (published = false AND auth.uid() = user_id); ```

    Pattern 3: Admin bypass

    ```sql CREATE POLICY "Admins bypass RLS" ON posts FOR ALL USING ( auth.jwt() ->> 'role' = 'admin' ); ```

    Verify in official docs: [Supabase RLS guide](https://supabase.com/docs/guides/auth/row-level-security) – check current syntax for auth.jwt() usage.

    ---

    Testing RLS Policies

    Use Supabase's SQL editor with the "Execute as" dropdown to test as different users:

    ```sql -- Execute as user 123e4567-e89b-12d3-a456-426614174000 SELECT * FROM posts; ```

    Or in JavaScript:

    ```javascript // Test as authenticated user const user = await supabase.auth.getUser(); console.log('Logged in as:', user.data.user.id);

    const { data, error } = await supabase .from('posts') .select('*');

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

    ---

    Common Gotchas

    1. RLS doesn't apply to service role key – Service role bypasses all policies. Use it only in trusted server contexts. 2. auth.uid() is NULL for anonymous users – Always handle anonymous access explicitly. 3. Policies are additive for SELECT – If two SELECT policies exist, a row is visible if it matches *either*. 4. UPDATE/DELETE policies use BOTH USING and WITH CHECK – USING filters visible rows; WITH CHECK validates the change.

    ---

    Debugging Checklist

  • [ ] RLS enabled on table? Check dashboard for lock icon.
  • [ ] Policies created? Run SELECT * FROM pg_policies WHERE tablename = 'your_table';
  • [ ] User authenticated? Verify auth.uid() returns non-NULL value in SQL editor.
  • [ ] Policy condition correct? Test with sample data: SELECT * FROM table WHERE {policy_condition};
  • [ ] Using wrong Supabase key? Should be anon key for client-side, never service role.
  • For Supabase library versions, verify in official docs: [JavaScript client library](https://supabase.com/docs/reference/javascript/release-notes).

    ---

    Related Reading

  • [PostgreSQL authentication in Supabase](/?guide=auth)
  • [Securing your API with policies](/?guide=security)
  • ---

    What am I missing?

    RLS is complex—policies interact with joins, aggregations, and real-time subscriptions in subtle ways. What edge cases have burned you? Missing a pattern for shared documents, team permissions, or testing strategies? Drop it in the comments.

    🔥 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