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:
- Calling an auth method on the admin client, such as
signInWithPassword()orverifyOtp(). The client stores that user's session and sends their JWT from then on. - Creating the admin client with
@supabase/ssrand cookies.createServerClientreads the user's session from cookies, which is what you want for a user client and wrong for an admin client. - Forwarding the caller's
Authorizationheader, for example in an Edge Function withglobal: { headers: { Authorization: req.headers.get('Authorization') } }. - 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
Make a dedicated admin client with plain
createClientand 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 } } )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.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_roleDrop the function when you're done.
Decode the key: its payload should contain
"role":"service_role".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.