Research / Guides

Troubleshooting guide

RLS blocks your own users or leaks their data: how to fix it

Sign-up fails with a row-level security error, a signed-in user gets an empty list of their own records, or a shared screen shows someone else's data. Nine builder reports in our data describe this pattern. It appears on Supabase, Firebase, Lovable, Base44 and hand-rolled backends. It usually surfaces just before launch or just after the first real customers sign in.

9 verified cases in our dataUpdated 2026-09-25How we collect cases

What you'll see

Why it happens

RLS denies by default and a per-operation policy is missing

When RLS is enabled and no policy applies, Postgres denies by default. No rows are visible or updatable, so a select returns an empty result instead of an error. Policies are per operation. An insert policy alone is not enough for insert().select(): rows returned by RETURNING must also pass a SELECT policy, and Postgres throws an error if they do not. Supabase's docs add that an UPDATE also needs a matching SELECT policy.

The profile row is written from the client before a session exists

With Confirm email switched on, Supabase's signUp returns a user but a null session. If the client then inserts into a profiles table, the request is anonymous and auth.uid() returns null. A comparison such as null = user_id is never true, so the WITH CHECK fails and sign-up breaks.

The policy compares the wrong column

A policy may compare auth.uid() against the row's own id instead of its owner column, or against a column that holds a different identifier. A policy may also trust raw_user_meta_data, which signed-in users can update themselves. Supabase's docs say that field is not a good place for authorization data. A role stored there lets users promote themselves.

Claims set on the server never reach the rule

In Firebase, custom claims set with the Admin SDK only appear in a user's ID token after one of three things: the user signs in again, the old token expires and is refreshed, or the client forces a refresh with getIdToken(true). Until then, a rule checking request.auth.token.admin sees the old token and denies the request. In Supabase, authorization data belongs in raw_app_meta_data, which users cannot change.

Something bypasses the rules and nobody checks

Tables in the public schema with RLS switched off are readable by anyone holding the publishable key. Views created by the postgres user bypass RLS by default. The service_role key has the bypassrls attribute, so a test or script run with it tells you nothing about what customers can reach.

How to fix it

  1. Create per-user rows in a trigger, not from the client

    Let the database create the profile when the auth user is inserted, so no client session is needed. A failing trigger blocks sign-ups, so test it before deploying.

    create function public.handle_new_user()
    returns trigger language plpgsql
    security definer set search_path = ''
    as $$
    begin
      insert into public.profiles (id) values (new.id);
      return new;
    end;
    $$;
    
    create trigger on_auth_user_created
      after insert on auth.users
      for each row execute procedure public.handle_new_user();
  2. Write one policy per operation against the owner column

    Give every operation the app uses its own policy. SELECT and DELETE use USING, INSERT uses WITH CHECK, and UPDATE uses both. If the app calls insert().select() or updates rows, it also needs a SELECT policy.

    alter table public.notes enable row level security;
    
    create policy "owner reads" on public.notes
      for select to authenticated
      using ((select auth.uid()) = user_id);
    
    create policy "owner inserts" on public.notes
      for insert to authenticated
      with check ((select auth.uid()) = user_id);
  3. Log the error and don't trust an empty array

    Log the error's code and message instead of rendering an empty list. If a user gets an empty result for data they own, the usual cause is a missing or mismatched SELECT policy. The data is usually still there.

    const { data, error } = await supabase
      .from('notes')
      .insert({ body })
      .select();
    if (error) console.error(error.code, error.message);
  4. Keep authorization data where users cannot write it

    In Supabase, store roles in raw_app_meta_data and never reference user_metadata in a policy. In Firebase, set claims with the Admin SDK, then call getIdToken(true) on the client before relying on the new claim.

  5. Fix storage policies on storage.objects

    Storage policies live on the storage.objects table. An upload needs an INSERT policy, and an upsert also needs SELECT and UPDATE. Scope each policy to the bucket, and to a folder named after the user's id using storage.foldername(name).

  6. Add a two-account test to CI

    Sign in as user A and run queries against user B's rows. Test both what must succeed and what must fail. Run supabase test db on every pull request and before every deploy.

    begin;
    select plan(2);
    set local role authenticated;
    set local request.jwt.claim.sub = '<user-a-uuid>';
    select is(
      (select count(*) from public.notes where user_id = '<user-b-uuid>'),
      0::bigint, 'A cannot read B');
    select throws_ok(
      $$insert into public.notes (user_id, body) values ('<user-b-uuid>', 'x')$$,
      '42501', null, 'A cannot write as B');
    select * from finish();
    rollback;

Check it's fixed

With Gemmein

Gemmein is in beta, and it doesn't ask you to write policies. Each collection gets one of seven fixed, plain-English rules (private, shared, public_read, community, addressed, direct, admin_write), and Gemmein's servers enforce it. Its MCP server (npx -y @gemmein/mcp) includes check_integration, which runs live isolation checks against the dev environment. It targets the class of bug that passes with one user and breaks with two. Reaffirm uses live calls in CI to prove the app's boundaries. The trade-off is deliberate: Gemmein has no custom roles, no team workspaces and no per-user record permissions. If you need SQL and full control, Supabase is the better choice.

Read more on gemmein.com →

Questions

Why does my Supabase query return an empty array with no error?

With RLS enabled, rows that no SELECT policy allows are filtered out silently instead of raising an error. Check that a SELECT policy exists for the role making the request, and that it compares auth.uid() with the right owner column.

Can I use the service_role key to get past the error?

Only in server code. The service_role key bypasses RLS completely, so if it ships to the browser, every visitor gets full access. Supabase's docs say never to expose it to customers.

Is it safe to store a user's role in user metadata?

No. The signed-in user can update raw_user_meta_data, so a policy that trusts it lets users grant themselves access. Use raw_app_meta_data, which users cannot change.

I set a Firebase custom claim but my rule still denies the request. Why?

The client is still sending the old ID token. The new claim arrives on the next sign-in, on the refresh after the old token expires, or immediately if you call getIdToken(true).

Do Postgres views respect my RLS policies?

Not by default, because views are usually created by the postgres user. On Postgres 15 or later, create them with security_invoker = true. On older versions, revoke access from anon and authenticated, or move the view to a schema the API doesn't expose.

Sources

Related guides

← Back to the full report: what breaks when AI apps go live