Supabase service_role key in the frontend
The service role is the admin key. RLS does not apply to it. If the public JavaScript contains a JWT whose payload says service_role, anyone can read and write every row.
What you see
- The app works when you are logged out. Opening the network tab shows a request whose apikey is a long JWT, and that request returns every customer's rows.
- A config file next to the anon key has a second JWT. One of them was copied from the service_role row in the Supabase dashboard.
- The key is in a variable named NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY or VITE_SUPABASE_SERVICE_ROLE_KEY, so the bundler inlines it into the JavaScript that ships to the browser.
Why this happens
- The service role was pasted into the same config object as the anon key.
- A server-only file was imported by a client component.
- The key was left in a comment or a debug script that still gets deployed.
Common causes
The two keys were treated as a pair
Supabase gives you an anon key and a service_role key. The anon key is public. The service_role key bypasses row level security. Generated apps often put both in the same client config because both were on the API settings screen.
How to check. Find every JWT in the client bundle. Decode the middle segment and read the role claim. anon may stay. service_role may not.
A public env prefix shipped the secret
Vite inlines variables that start with VITE_. Next.js inlines variables that start with NEXT_PUBLIC_. A service_role value under either prefix is in the built JavaScript, even if the source file looks like a server module.
How to check. Search the deployed JavaScript for service_role. Then search the environment screen for that value under a VITE_ or NEXT_PUBLIC_ name.
Fix it
1. Tell the two keys apart
Both legacy keys are JWTs. The payload, the middle segment, has a role claim. The anon key says anon. The service_role key says service_role. A newer Supabase secret key is not a JWT and does not belong in the browser either. The publishable key does.
const segment = token.split(".")[1];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, "+").replace(/_/g, "/")));
console.log(payload.role); // "anon" or "service_role"2. Keep only the anon key in client code
The browser client uses the project URL and the anon key. Server code, if you have any, reads the service_role key from an environment variable with no public prefix. After this change, rebuild. A Vite or Next build copies public env values at build time, so editing the host after the build does nothing until you build again.
const supabase = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, ); // Server only. No VITE_ or NEXT_PUBLIC_ prefix. const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
3. Rotate the exposed service_role key
Removing the key from the next deploy does not un-copy the old one. In the Supabase dashboard, regenerate the service_role key, put the new value only in the server environment, and redeploy the server. Sessions that used the anon key keep working.
How to verify
- Rebuild, open the deployed site, and search the HTML and same-origin scripts for service_role. There should be no match.
- Decode every remaining eyJ token in that JavaScript. The role claim should be anon, or the token should be a logged-in user's access token that the app stored after login, not a key baked into the bundle.
- Load the app in a private window. Login still works with the anon key. A request that sends only the anon key returns an empty array or an error for private tables, which is the row level security check, not a reason to put the service_role key back.
What BearWatch looks for
- BearWatch decodes JWTs in the public page and scripts and flags role service_role.
- It uses only the anon key for the read test, so a service role finding is not confused with a public anon key.
A public look, before the daily check
The free scan and the free roast both read the public HTML and same-origin scripts. A JWT whose role is service_role is reported. The anon key is not reported as a secret. The scan also requests tables with that anon key, so you can see whether the public key can read rows.
Prompt to paste
The Supabase service_role key is in frontend code. Remove it, rotate it in the Supabase dashboard, and keep it in a server-only environment variable. The browser may keep the anon key. Decode JWTs in the bundle and confirm the role is anon, not service_role. Turn RLS on so the anon key cannot list other people's rows.