Supabase RLS Policy Blocks Data

Empty results despite data in the table. RLS policy too strict.

You can see 100 rows in the posts table in Supabase Dashboard’s Table Editor. The frontend calls await supabase.from('posts').select('*') and gets [] back — no error. Or weirder: the logged-in user sees their own posts but no one else’s, even though you wrote a public feed.

That’s Supabase RLS (Row Level Security) silently blocking you. The default behavior is enabled + no policy = deny all, with no error, just 0 rows. “Silent empty result” is the textbook RLS symptom.

Common causes

Ordered by hit rate, highest first.

1. RLS enabled, no SELECT policy

Most common. You hit “Enable RLS” but never added any policy → all queries return 0 rows. The Dashboard shows a warning next to the table name, but it’s easy to miss.

How to spot it: Dashboard → Table → small red “RLS enabled, 0 policies” warning?

2. Policy needs auth.uid() but caller is anonymous

CREATE POLICY "users see own posts" ON posts
  FOR SELECT USING (auth.uid() = author_id);

Anonymous user → auth.uid() is null → always false → sees nothing.

How to spot it: Logged-in works / logged-out doesn’t.

3. Client uses anon key instead of service_role

Frontend uses SUPABASE_ANON_KEY → subject to RLS. You might have assumed supabase-js auto-bypasses — it doesn’t.

How to spot it: Same query with service_role in SQL editor returns data → RLS is the blocker.

4. SELECT policy exists but INSERT/UPDATE policies missing

-- Only added a SELECT policy
CREATE POLICY "see own" ON posts FOR SELECT USING (...);

INSERT still deny. Or reverse: INSERT works but SELECT after insert returns nothing (no SELECT policy).

How to spot it: Can write but can’t read what you just wrote.

5. Wrong join in the policy

CREATE POLICY "team members see post" ON posts
  USING (
    EXISTS (
      SELECT 1 FROM team_members
      WHERE team_id = posts.team_id  -- ❌ should be user_id = auth.uid()
        AND user_id = posts.author_id  -- wrong logic
    )
  );

Wrong logic = always false.

How to spot it: Policy looks sensible but no user sees anything.

6. JWT missing / expired

Frontend didn’t pass the session to the Supabase client, or the token expired. auth.uid() resolves to null.

How to spot it: console.log(supabase.auth.getSession()) — is the session present and not expired?

Shortest path to fix

Step 1: Confirm data exists (verify with service_role)

-- Supabase Dashboard → SQL Editor
-- SQL editor uses postgres role by default → bypasses RLS
SELECT count(*) FROM posts;  -- 100
SELECT * FROM posts LIMIT 5;  -- should return data

If this returns data → confirmed RLS is blocking, not a data issue.

Step 2: List policies on the table

SELECT * FROM pg_policies WHERE tablename = 'posts';

Or Dashboard → Database → Tables → posts → Policies.

Step 3: Add a SELECT policy

Simplest “everyone can read”:

CREATE POLICY "anyone can read posts" ON posts
  FOR SELECT
  TO anon, authenticated
  USING (true);

Safer “authenticated reads published”:

CREATE POLICY "authenticated read published" ON posts
  FOR SELECT
  TO authenticated
  USING (status = 'published');

Or “own + public”:

CREATE POLICY "own or public" ON posts
  FOR SELECT
  TO authenticated
  USING (author_id = auth.uid() OR is_public = true);

Deploy: run in SQL Editor, or commit to supabase/migrations/:

supabase migration new add_posts_select_policy
# Edit the generated SQL file
supabase db push

Step 4: Per-operation policies

-- Read: all authenticated
CREATE POLICY "auth read" ON posts FOR SELECT
  TO authenticated USING (true);

-- Insert: only self
CREATE POLICY "own insert" ON posts FOR INSERT
  TO authenticated WITH CHECK (author_id = auth.uid());

CREATE POLICY "own update" ON posts FOR UPDATE
  TO authenticated USING (author_id = auth.uid());

CREATE POLICY "own delete" ON posts FOR DELETE
  TO authenticated USING (author_id = auth.uid());

Step 5: Confirm client passes the token

// Frontend
const { data: session } = await supabase.auth.getSession();
console.log('session:', session); // should have access_token

// supabase-js auto-attaches when session exists, but confirm
const { data, error } = await supabase.from('posts').select('*');
console.log(data, error);

session is null → user not logged in, query goes as anon.

Step 6: Debug policy matching

-- In SQL Editor, simulate an authenticated user
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" TO '{"sub": "your-user-uuid"}';
SELECT * FROM posts;
RESET ROLE;

See which policy lets rows through.

Step 7: Server-side bypass with service_role

Some server use cases (cron, admin endpoint) genuinely need full access:

import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,  // ⚠️ server-only
  { auth: { autoRefreshToken: false, persistSession: false } }
);

const { data } = await supabaseAdmin.from('posts').select('*');  // bypasses RLS

⚠️ service_role key must never reach the client.

Prevention

  • Write policies alongside table creation; commit via git migrations, not Dashboard hand-edits
  • Never disable RLS in production — wide-open table data via anon key is a disaster
  • Each table defaults to at least four policies (SELECT / INSERT / UPDATE / DELETE), tightened as needed
  • Always scope policies with TO authenticated or TO anon; don’t leave the default role
  • For complex policies, self-test with SET LOCAL ROLE
  • service_role key only in server-side env; frontend stays on anon key
  • Periodically audit in test: “as anon, which tables can I read?” Catch accidental exposure
  • Document per-table “who reads / who writes” so new contributors author policies correctly

Tags: #Backend #Debug #Troubleshooting #Supabase