service_role client still gets RLS errors

Why a supabase-js client created with the service_role key can still hit row-level security, usually because a user session replaced the key, and how to build a clean admin client.

The service_role key bypasses Row Level Security, but only if the request actually runs as service_role. When an admin client still hits RLS, the request is going out with a user's token instead of the key.

What you see

Server code created with SUPABASE_SERVICE_ROLE_KEY gets errors you'd expect from a signed-in user:

new row violates row-level security policy for table "invoices"

or reads come back as an empty array, or Storage returns 403.

Why it happens

PostgREST and Storage pick the role from the Authorization: Bearer token, not from the apikey header. supabase-js sends the service key as the bearer token only while the client has no user session. Things that put a user session in the client:

  1. Calling an auth method on the admin client, such as signInWithPassword() or verifyOtp(). The client stores that user's session and sends their JWT from then on.
  2. Creating the admin client with @supabase/ssr and cookies. createServerClient reads the user's session from cookies, which is what you want for a user client and wrong for an admin client.
  3. Forwarding the caller's Authorization header, for example in an Edge Function with global: { headers: { Authorization: req.headers.get('Authorization') } }.
  4. Using the anon key by mistake. Both keys are long JWTs and easy to swap in environment variables.

A separate case: service_role bypasses RLS but not grants. If someone ran revoke all on public.invoices from service_role, you'll get permission denied for table invoices (42501) even with a correct admin client.

How to fix it

  1. Make a dedicated admin client with plain createClient and session handling turned off. Use it only on the server:

    import { createClient } from '@supabase/supabase-js'
    
    export const supabaseAdmin = createClient(
      process.env.SUPABASE_URL,
      process.env.SUPABASE_SERVICE_ROLE_KEY,
      { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } }
    )
    
  2. Never call sign-in methods on it. To act for a user, keep a second client built from their session. To manage users, use supabaseAdmin.auth.admin.*, which doesn't change the client's session.

  3. Check which role a request really uses:

    create or replace function public.whoami()
    returns text language sql stable as $$ select current_user::text $$;
    
    const { data } = await supabaseAdmin.rpc('whoami')
    console.log(data) // should print service_role
    

    Drop the function when you're done.

  4. Decode the key: its payload should contain "role":"service_role".

  5. If the role is right and you still get 42501, restore the grants:

    grant all on public.invoices to service_role;
    

On SupaHost

Find both keys on Dashboard → your project → Connect tab → App. The anon key is labelled "public" and service_role "secret", behind a Reveal button that only the owner can use. Edge Functions get the key as SUPABASE_SERVICE_ROLE_KEY automatically.

If a request carries apikey but no Authorization header, the SupaHost gateway copies the key into Authorization: Bearer. A raw curl with only the service key therefore runs as service_role. When it works with curl but not in your app, the app is sending a different bearer token.

Never ship the service_role key in browser or mobile code.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us