Supabase RLS Returns an Empty Array (No Error)

Your Supabase table has rows but the frontend gets [] with no error. Diagnose which RLS bucket you're in and add the right SELECT policy in minutes.

You can see 100 rows in the posts table in the Supabase 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 Row Level Security (RLS) silently filtering you out. RLS is enabled by default on every table you create in the Table Editor, and the default of enabled + no matching policy = deny every row. Postgres does not raise an error on a denied SELECT; it just removes the rows, so you get an empty array, not a 42501 permission error. “Silent empty result” is the textbook RLS symptom and the official Supabase troubleshooting answer for “I have data but my select returns []”.

TL;DR — fastest fix: run SELECT * FROM pg_policies WHERE tablename = 'posts'; in the SQL Editor. If it returns zero rows, you have RLS on with no policy. Add one SELECT policy (template below), re-run your frontend query, done. If policies do exist, jump to the diagnosis table to find which clause is rejecting your rows.

Which bucket are you in

Match your symptom to the row, then go straight to the fix.

SymptomMost likely causeFix
[] for everyone, logged in or notRLS on, no SELECT policyAdd a SELECT policy (Step 3)
Logged-in user sees their rows, others see nothingPolicy gated on auth.uid(), no public clauseAdd an OR is_public clause (Step 3)
Works in SQL Editor, [] from the appApp uses the publishable/anon key (RLS applies); editor runs as postgres (bypasses RLS)Add a policy for the anon/authenticated role
Can INSERT but the row you just wrote reads back as []INSERT policy exists, SELECT policy missingAdd a matching SELECT policy (Step 4)
Policy looks correct but still [] for everyoneBad join/logic in the USING clause, or a RESTRICTIVE policy with no matching PERMISSIVE oneRe-check the clause; confirm the policy is PERMISSIVE (Step 6)
Was working, suddenly [] after loginJWT not attached or expired → auth.uid() is nullRe-check the session (Step 5)

Common causes

Ordered by hit rate, highest first.

1. RLS enabled, no SELECT policy

Most common. The table was created in the Table Editor (RLS on by default) or you clicked “Enable RLS” and never added a policy, so every query returns 0 rows. The Dashboard shows an “RLS enabled, no policies” warning next to the table, but it’s easy to miss.

How to spot it: Table Editor → your table → a small “RLS enabled, 0 policies” / “Unprotected” badge, or run the pg_policies query above and get nothing back.

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

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

An anonymous request has a null auth.uid(), so the comparison is always false and returns nothing.

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

3. Client uses the publishable/anon key (so RLS applies)

The browser client uses your publishable key (sb_publishable_...) — or the legacy anon key on older projects. Both run as the anon/authenticated role and are fully subject to RLS. A common wrong assumption is that supabase-js bypasses RLS; it does not. Only the secret key (sb_secret_..., formerly service_role) bypasses RLS, and it must never ship to the browser.

As of June 2026 Supabase has migrated to publishable (sb_publishable_...) and secret (sb_secret_...) API keys. The old anon / service_role JWT keys still work but are deprecated and scheduled for removal in late 2026; projects created after November 2025 may not have them at all. Find both new keys under Settings → API Keys → Publishable and secret API keys.

How to spot it: the same query in the SQL Editor (runs as postgres, bypasses RLS) returns data, but the app returns [].

4. SELECT policy exists but INSERT/UPDATE policies are missing

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

INSERT is still denied. Or the reverse: INSERT works but reading the row back returns nothing because there’s no matching SELECT policy. Each operation needs its own policy.

How to spot it: you can write but can’t read what you just wrote (or vice versa).

5. Wrong join/logic in the policy

CREATE POLICY "team members see post" ON posts
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM team_members
      WHERE team_id = posts.team_id  -- correct table link...
        AND user_id = posts.author_id  -- but this never matches the caller
    )
  );

The membership check compares against posts.author_id instead of auth.uid(), so it’s effectively always false. Right shape, wrong predicate.

How to spot it: the policy reads sensibly but nobody sees any rows.

6. JWT missing / expired

The frontend never passed the session to the Supabase client, or the access token expired, so auth.uid() resolves to null.

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

7. A RESTRICTIVE policy with no PERMISSIVE policy to allow rows

Policies are PERMISSIVE by default (any one match grants access). If you created an AS RESTRICTIVE policy, it can only subtract access — it never grants rows on its own. With only restrictive policies and no permissive SELECT policy, the result is still [].

How to spot it: SELECT polname, polpermissive FROM pg_policy WHERE polrelid = 'posts'::regclass; — if every row shows polpermissive = f, you have no permissive policy granting reads.

Shortest path to fix

Step 1: Confirm the data exists (run as postgres, bypassing RLS)

-- Supabase Dashboard → SQL Editor
-- The SQL Editor runs as the postgres role, which bypasses RLS
SELECT count(*) FROM posts;   -- expect 100
SELECT * FROM posts LIMIT 5;  -- should return rows

If this returns data, RLS is the blocker, not a missing/empty table.

Step 2: List the policies on the table

SELECT * FROM pg_policies WHERE tablename = 'posts';

Or open the Dashboard → Authentication → Policiesposts (you can also reach it from the Table Editor’s RLS badge). Zero rows here confirms cause #1.

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 users read published rows”:

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

Or “own rows plus anything public”:

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

Deploy by running it in the SQL Editor, or commit it to supabase/migrations/ so the change is reproducible:

supabase migration new add_posts_select_policy
# Paste the CREATE POLICY statement into the generated SQL file
supabase db push

Step 4: Per-operation policies

A separate policy is required per operation. Note INSERT uses WITH CHECK, while SELECT/UPDATE/DELETE use USING (UPDATE can use both).

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

-- Insert: only as yourself
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())
  WITH CHECK (author_id = auth.uid());

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

Step 5: Confirm the client is sending the token

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

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

If session is null, the user is not logged in and the query runs as anon — so an auth.uid()-gated policy will return nothing.

Step 6: Reproduce the policy check inside the SQL Editor

You can simulate an exact role + user and watch which rows survive the policy:

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

If this returns rows but your app does not, the app isn’t sending that JWT (back to Step 5). If even this returns [], your USING clause is rejecting the rows — re-read cause #5 and #7.

Step 7: Server-side bypass with the secret key

Some server use cases (cron jobs, admin endpoints, trusted backend services) genuinely need full access. Use the secret key (sb_secret_..., or the legacy service_role key on older projects):

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

const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!, // sb_secret_... — server-only, bypasses RLS
  { auth: { autoRefreshToken: false, persistSession: false } }
);

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

The secret/service_role key must never reach the browser. Keep it in a server-only environment variable; never prefix it with NEXT_PUBLIC_, VITE_, or PUBLIC_.

How to confirm it’s fixed

  1. Re-run the failing frontend call and log both fields: const { data, error } = await supabase.from('posts').select('*'); console.log(data?.length, error); — you want a non-zero length and a null error.
  2. Confirm the policy is live and permissive: SELECT polname, polpermissive FROM pg_policy WHERE polrelid = 'posts'::regclass; should list your new policy with polpermissive = t.
  3. Test the negative case too: log out (or use an anon client) and verify you only see what should be public. Getting rows you didn’t intend to expose means your USING (true) is too wide.

Prevention

  • Write policies alongside table creation and commit them via git migrations, not Dashboard hand-edits.
  • Never disable RLS in production. A table left open to the publishable/anon key is a data-leak waiting to happen.
  • Give each table its four operations explicit policies (SELECT / INSERT / UPDATE / DELETE), tightened as needed.
  • Always scope a policy with TO authenticated or TO anon; an unscoped policy applies to every role, including anon.
  • For non-trivial policies, self-test with SET LOCAL ROLE before shipping.
  • Keep the secret (sb_secret_... / service_role) key in server-side env only; the browser stays on the publishable/anon key.
  • Periodically audit as anon: “which tables can an unauthenticated user read?” to catch accidental exposure.
  • Document each table’s “who reads / who writes” so new contributors author policies correctly.

FAQ

Why does RLS return an empty array instead of a permission error? Postgres treats a denied SELECT as a row filter, not an access violation, so the rows are simply removed from the result set. Only INSERT/UPDATE/DELETE violations raise an error (42501/new row violates row-level security policy). An empty SELECT with error === null is the expected RLS signature.

Do the new publishable keys change how RLS behaves? No. The publishable key (sb_publishable_...) carries the same low privileges as the old anon key, so your RLS policies behave identically. Only the secret key (sb_secret_..., formerly service_role) bypasses RLS.

Should I just turn RLS off to make it work? No. Disabling RLS exposes the whole table to anyone holding the publishable/anon key (which ships in your client bundle). Add a scoped SELECT policy instead — that’s the fix, not a workaround.

It works in the SQL Editor but not in my app — why? The SQL Editor runs as the postgres role and bypasses RLS, while your app runs as anon or authenticated and is subject to it. A query that works in the editor but returns [] in the app almost always means a missing or non-matching policy for that role.

How do I test a policy as a specific logged-in user? In the SQL Editor, run SET LOCAL ROLE authenticated; then SET LOCAL "request.jwt.claims" TO '{"sub":"<uuid>","role":"authenticated"}'; before your SELECT, and RESET ROLE; after. This reproduces exactly what that user would see through the API.

Tags: #Backend #Debug #Troubleshooting #Supabase