# 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.

Source: https://supahost.app/kb/service-role-rls-errors · Updated 2026-11-11

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:

   ```js
   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:

   ```sql
   create or replace function public.whoami()
   returns text language sql stable as $$ select current_user::text $$;
   ```

   ```js
   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:

   ```sql
   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.

## Related

- [Admin tasks on the server with the service_role key](/kb/server-side-admin-tasks)
- [API keys](/docs/api/api-keys)
- [Keeping keys safe](/docs/security/api-key-safety)
- ["permission denied for table" (42501) errors](/kb/permission-denied-42501)
