Fix: Supabase Not Working — RLS Policy Blocking Queries, Realtime Not Receiving Updates, or Auth Session Lost
Quick Answer
How to fix Supabase issues — Row Level Security policies, realtime subscriptions, storage permissions, auth session with Next.js, edge functions, and common client configuration mistakes.
The Problem
A Supabase query returns no data even though rows exist in the table:
const { data, error } = await supabase.from('posts').select('*');
console.log(data); // [] — empty array
console.log(error); // null — no error, just no dataOr realtime subscriptions don’t receive updates:
const channel = supabase
.channel('posts')
.on('postgres_changes', { event: '*', schema: 'public', table: 'posts' }, (payload) => {
console.log('Change:', payload);
})
.subscribe();
// Updates happen in the database but the callback never firesOr the auth session is lost after navigating or refreshing:
const { data: { session } } = await supabase.auth.getSession();
// session is null after page refresh — user appears logged outOr storage upload fails with a 403:
const { error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file);
// StorageError: new row violates row-level security policyWhy This Happens
Supabase is opinionated about security — most issues come from misconfigured permissions:
- Row Level Security (RLS) is enabled by default on new tables — with RLS enabled and no policies, no rows are accessible to anyone. Queries return empty results, not errors. This is the #1 source of confusion.
- Realtime requires explicit publication — tables must be added to the
supabase_realtimepublication for change events to fire. New tables aren’t included automatically. - Auth session requires correct SSR setup — in server-side rendered apps (Next.js, SvelteKit), you must use
@supabase/ssrto handle cookie-based session persistence. The browser client doesn’t work on the server. - Storage policies are separate from table policies — even if your table policies are correct, storage buckets have their own RLS-equivalent policies.
Fix 1: Fix Row Level Security Policies
RLS being enabled with no policies = no access. You must explicitly allow what should be accessible:
-- Check if RLS is enabled on a table
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
-- Enable RLS (it's on by default for new tables via Supabase dashboard)
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Allow public read access (no auth required)
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (true);
-- Allow users to insert their own posts
CREATE POLICY "Users can create their own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Allow users to update their own posts
CREATE POLICY "Users can update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Allow users to delete their own posts
CREATE POLICY "Users can delete their own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
-- Admin access — check a role column
CREATE POLICY "Admins can do anything"
ON posts
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);Common RLS patterns:
-- Tenant isolation (multi-tenant app)
CREATE POLICY "Users see own org data"
ON documents FOR SELECT
USING (
org_id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid()
)
);
-- Service role bypass — useful for server-side operations
-- Use service role key in server, which bypasses RLS entirely
-- Never expose the service role key in the browser
-- Check JWT claims (e.g., from a custom claim set via trigger)
CREATE POLICY "Users with admin role"
ON admin_data FOR SELECT
USING (
(auth.jwt() ->> 'user_role') = 'admin'
);Debug RLS issues:
-- Test a policy as a specific user
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub": "user-uuid-here"}';
SELECT * FROM posts; -- Returns what that user would see
RESET role;
-- Check which policies exist on a table
SELECT * FROM pg_policies WHERE tablename = 'posts';Fix 2: Fix Realtime Subscriptions
// Ensure the table is in the realtime publication (do this once in SQL editor)
-- Add table to realtime
ALTER PUBLICATION supabase_realtime ADD TABLE posts;
-- Verify
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';// Correct realtime subscription setup
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Subscribe to all changes on a table
const channel = supabase
.channel('posts-changes') // Unique channel name
.on(
'postgres_changes',
{
event: '*', // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
schema: 'public',
table: 'posts',
filter: 'user_id=eq.' + userId, // Optional: filter by column
},
(payload) => {
console.log('Event type:', payload.eventType);
console.log('New record:', payload.new);
console.log('Old record:', payload.old); // Only for UPDATE/DELETE
}
)
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('Listening for changes...');
}
if (status === 'CHANNEL_ERROR') {
console.error('Channel error');
}
});
// IMPORTANT: Unsubscribe when component unmounts
// React useEffect pattern:
useEffect(() => {
const channel = supabase
.channel('posts')
.on('postgres_changes', { event: '*', schema: 'public', table: 'posts' }, handler)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);Broadcast (presence and custom events):
// Broadcast custom events between clients (doesn't touch the database)
const channel = supabase.channel('room-1');
channel
.on('broadcast', { event: 'cursor-pos' }, ({ payload }) => {
updateCursor(payload.userId, payload.x, payload.y);
})
.subscribe();
// Send broadcast
channel.send({
type: 'broadcast',
event: 'cursor-pos',
payload: { userId: currentUserId, x: 100, y: 200 },
});
// Presence — track who's online
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
console.log('Online users:', Object.keys(state));
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ userId: currentUserId, online: true });
}
});Fix 3: Fix Auth in Next.js App Router
Standard Supabase browser client doesn’t work in Next.js server components. Use @supabase/ssr:
npm install @supabase/ssr @supabase/supabase-js// utils/supabase/client.ts — browser client
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// utils/supabase/server.ts — server client (reads cookies)
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}// middleware.ts — refresh session on every request
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// Refresh session — don't remove this
const { data: { user } } = await supabase.auth.getUser();
// Protect routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};// app/dashboard/page.tsx — server component with auth
import { createClient } from '@/utils/supabase/server';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
const { data: posts } = await supabase
.from('posts')
.select('*')
.eq('user_id', user.id);
return <PostList posts={posts ?? []} />;
}Fix 4: Configure Storage Policies
Storage buckets use a similar policy system to RLS:
-- Allow authenticated users to upload to their own folder
CREATE POLICY "Users upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Folder structure: avatars/{user-id}/avatar.png
-- Allow public read
CREATE POLICY "Avatars are publicly viewable"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
-- Allow users to update/delete their own files
CREATE POLICY "Users manage own files"
ON storage.objects FOR DELETE
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);Client-side upload:
async function uploadAvatar(userId: string, file: File) {
const supabase = createClient();
const fileExt = file.name.split('.').pop();
const filePath = `${userId}/avatar.${fileExt}`;
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file, {
upsert: true, // Overwrite if exists
contentType: file.type,
});
if (error) throw error;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(filePath);
return publicUrl;
}Fix 5: Use the Service Role for Server Operations
The anon key respects RLS. The service role key bypasses it — use it only on your server:
// lib/supabase-admin.ts — server-only file
import { createClient } from '@supabase/supabase-js';
// Service role client — bypasses RLS
// NEVER expose this key in the browser
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // NOT the anon key
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// Server-only admin operations
export async function deleteUserAccount(userId: string) {
// Bypass RLS to delete user's data
await supabaseAdmin.from('posts').delete().eq('user_id', userId);
// Delete auth user
await supabaseAdmin.auth.admin.deleteUser(userId);
}Edge Functions with service role:
// supabase/functions/process-webhook/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, // Service role in edge function
);
const payload = await req.json();
await supabase.from('webhook_logs').insert({ payload });
return new Response('OK');
});Still Not Working?
Query returns empty array but dashboard shows data — RLS is the almost certain cause. Use the Supabase dashboard → Table Editor → select the table → click “RLS” to see policies. Temporarily disable RLS to confirm: ALTER TABLE posts DISABLE ROW LEVEL SECURITY; (don’t leave this in production). Then write a policy that matches your access pattern.
auth.getSession() returns session but auth.getUser() returns null — in Supabase v2, getUser() makes a server request to verify the JWT. getSession() reads the locally stored session without verification. For security, always use getUser() on the server. If getUser() returns null, the JWT is expired or invalid — the session needs refreshing, which the middleware handles.
Realtime subscription connects but never fires — check: (1) the table is in supabase_realtime publication, (2) you’re subscribed before the changes happen, (3) the filter matches actual data (filters are case-sensitive), (4) your Supabase plan supports realtime (free tier has connection limits). Check the Supabase dashboard → Realtime → Inspector to see live events.
For related database issues, see Fix: PostgreSQL Row Level Security Not Working and Fix: Firebase Permission Denied.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.