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 authenticatedorTO 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