Skip to content

Fix: Supabase Not Working — RLS Policy Blocking Queries, Realtime Not Receiving Updates, or Auth Session Lost

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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 data

Or 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 fires

Or 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 out

Or 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 policy

Why This Happens

Supabase puts security and reactivity behind explicit configuration, and most “not working” symptoms come from missing one of those config steps.

The first source of confusion is Row Level Security. RLS is enabled by default on new tables created through the dashboard, and a table with RLS enabled and no policies is invisible to everyone — anon and authenticated alike. Queries against it return data: [] and error: null, which makes it look like the table is empty. RLS evaluation happens after the SQL runs, so PostgREST silently filters rows that don’t match a policy. The fix is to add explicit CREATE POLICY statements for each role and operation you want to allow.

The second source is key confusion. Supabase issues two keys: the anon key (which respects RLS as the anon Postgres role) and the service_role key (which bypasses RLS entirely). If you’re querying from the browser with the anon key but expecting the data the dashboard shows you (where you act as service_role), you’ll see the RLS-filtered view, not the full table. Equally common: deploying server-side code with the anon key when you needed service_role, or accidentally exposing service_role to the browser via NEXT_PUBLIC_* env vars.

The third source is JWT lifecycle. Supabase access tokens expire after one hour by default. The client refreshes them automatically in the browser, but in SSR contexts (Next.js App Router, SvelteKit, Remix) the refresh needs cookie-based session persistence via @supabase/ssr. If you used @supabase/supabase-js directly on the server, the session won’t survive a page refresh. Calling auth.getUser() after expiry returns null even though auth.getSession() happily returns a stale token from local storage.

The fourth source is realtime publication. Postgres logical replication only emits change events for tables explicitly added to the supabase_realtime publication. New tables aren’t included automatically, so your subscription connects, subscribes, and never fires.

Diagnostic Timeline

Minute 0 — Query returns empty array. Your first instinct is to check RLS, which is right, but most people don’t check it correctly. Open the Supabase dashboard → SQL editor and run SELECT * FROM your_table LIMIT 5 as the postgres role. If data shows up there, the table has rows. Now run SELECT * FROM pg_policies WHERE tablename = 'your_table'. Zero rows means no policies exist, which means RLS blocks everything.

Minute 3 — Identify which key you’re using. Open the network tab in devtools, find the request to /rest/v1/your_table, and decode the apikey header at jwt.io. The role claim tells you whether you’re hitting Supabase as anon or service_role. If you expected authenticated access, the JWT should also have a sub claim with a UUID — if not, the user isn’t signed in.

Minute 7 — Check the SSR client. In Next.js, the browser client and server client are different. If getUser() returns the user in the browser but null in a server component, you’re using the wrong client constructor on the server. Use createServerClient from @supabase/ssr and pass it the cookie store from next/headers.

Minute 12 — Local vs remote schema drift. If you’re developing with the Supabase CLI’s local stack, the migrations applied to your local Postgres may not match production. Run npx supabase db diff to see drift. Common symptom: a policy that exists locally but not in production, or a column type change that breaks an existing policy expression.

Minute 18 — Realtime subscribes but never fires. Open the Supabase dashboard → Database → Replication and confirm the table is listed under the supabase_realtime publication. If it isn’t, run ALTER PUBLICATION supabase_realtime ADD TABLE your_table in the SQL editor. The subscription will start firing immediately — no client restart needed.

Minute 25 — JWT expired in production but not locally. Local sessions can last days because the CLI’s auth server uses long expiries. Production access tokens expire in 60 minutes. If your app worked yesterday and breaks today after the user reloads, the refresh token wasn’t persisted because the cookie domain doesn’t match. Check that Set-Cookie headers from /auth/v1/token use the correct domain and SameSite=Lax.

Minute 35 — Storage 403 despite “correct” RLS. Storage policies live on storage.objects, not on a custom table. Even with permissive table policies, you need a separate CREATE POLICY ... ON storage.objects FOR INSERT WITH CHECK (...) for the bucket. The (storage.foldername(name))[1] trick is the canonical way to scope uploads per user.

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.

RLS policy works locally but blocks queries in production — the most common cause is a column type or schema drift between local and remote. A policy like auth.uid() = user_id requires user_id to be uuid, not text. If you ran an older migration that created user_id as text in production, the comparison fails silently. Run \d your_table in psql against both and compare.

auth.uid() returns null inside a policy when called from a server-side function — RLS policies run as the role that connected, and Postgres functions invoked from server-side code may run as service_role, where auth.uid() is always null. If you need the user identity inside a function, pass it explicitly as a parameter or set the JWT via auth.set_session_jwt(...) before the call.

Migration via supabase db push errors with “permission denied for schema public” — the migration includes a CREATE POLICY referencing auth.users, but the migration runs as postgres (not supabase_admin), which doesn’t own the auth schema. Either move policies that reference auth.users into the dashboard or grant USAGE ON SCHEMA auth to postgres explicitly at the top of the migration.

For related database and auth issues, see Fix: PostgreSQL Row Level Security Not Working, Fix: Firebase Permission Denied, Fix: Next.js Middleware Not Running, and Fix: Drizzle ORM Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles