WhoopsBot

Guides · Updated 2026-10-22

A Supabase service_role key ended up in the browser

How a Supabase service_role key gets into a frontend bundle, how to find it, how to rotate it, and what Row Level Security does and does not cover.

What that key is allowed to do

Supabase gives a project two keys that both look like long JWTs. The anon key is meant to ship in the browser. The service_role key is not. The service role bypasses Row Level Security. A request made with it can read and write every row, regardless of the policies you wrote for signed-in users. Anyone who can load a page that contains the key can copy it and use it from their own machine. Hiding it in a minified file does not hide it. Minified JavaScript is still downloaded by the browser.

The anon key being visible is normal. Security for that key is RLS plus policies, not secrecy. The service role key being visible is a leak even if every table has RLS turned on, because the key does not consult those policies.

How it gets into the bundle

The usual path is a public environment variable. Next.js inlines any variable whose name starts with NEXT_PUBLIC_ into the client bundle. Vite does the same for VITE_. If the service role key was saved as NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY, or VITE_SUPABASE_SERVICE_ROLE_KEY, the production build copies the value into JavaScript and serves it to every visitor. The preview may have worked either way, which is why the leak shows up after publish, or why it was there the whole time and nobody looked.

The other path is a shared module. A file that creates a Supabase client with the service role key gets imported by a client component, and the bundler includes it because the import graph says it is needed in the browser. Pasting the key into a Lovable or Cursor chat can do the same thing if the generated code puts it in a component instead of a server route. Committing a .env file and then publishing the project directory, rather than the build output, can expose the file itself. That is a different hole, and it is worth checking at the same time.

How to check

Open the published site, view source, and open the script files it loads. Search for service_role. Also search for the project URL next to a second JWT that is not the anon key you intended to ship. In the network panel, a request to your Supabase REST URL that sends the service role key in the apikey header or the Authorization bearer token is the same bug, caught in motion.

WhoopsBot's free check reads the JavaScript on the one page you give it and flags a service_role-shaped key. It does not print the key back in the report. It does not send the key to your database to see what it can do. It does not test Row Level Security. A clean result means that page's scripts did not contain one of the patterns it looks for. It does not mean every route on the site is clean, and it does not mean your policies are right.

Search the repository too. A key that is only on your laptop is not in the bundle yet, but it will be if the next build inlines it. Look for the variable name as well as the value. Then request /.env on the published host, in case the file was deployed beside the app. HTML that comes back from that path is a router fallback, not proof of a leak. A body full of KEY=value lines is a leak.

How to fix the code

Remove the service role key from every client file and every public environment variable. The browser client should be createClient(url, anonKey). The service role client, if you need one, lives in a server route, a server action, or an edge function whose source is not sent to the browser. That server reads the key from an environment variable that has no public prefix.

Rebuild and redeploy after the removal. The old JavaScript file can stay on a CDN after you fixed the source. Load the published page again and search the scripts. If the key is still in a hashed filename, purge that cache or wait out the cache header, then search again. Do this before you celebrate the diff.

Rotate the key

Deleting the line does not un-leak the value. In the Supabase dashboard, under Project Settings and API keys, rotate the service_role secret. The old JWT stops working. Update the server environment with the new value and restart whatever holds it. Do not put the new value in a NEXT_PUBLIC_ or VITE_ variable, or you have published the replacement.

If the anon key was also in a file you did not mean to publish, you can rotate it too, but every shipped client has to receive the new anon key in a fresh build. Rotating the anon key is inconvenient. Rotating the service role key is not optional once it has been in a bundle or a public .env. Assume it was copied. You will not get a download log from the people who took it.

RLS, in the amount you actually need

Row Level Security is a switch per table, plus policies that say which rows a role may touch. With RLS off, the anon key can read and write the table through the public API, subject to grants. Turn RLS on for every table that holds user data. With RLS on and no policies, the anon and authenticated roles see no rows. The service role still sees all of them, which is why that key cannot live in the browser even after you enable RLS.

A useful policy names the role and the operation. Authenticated users can select rows where the user id column equals auth.uid(). Inserts require the same check, or a person can create rows as someone else. Update and delete need their own policies. A policy that only covers select leaves updates wide open or fully blocked, depending on what you wrote. Test with the anon key and a real user session. A query that uses the service role key will succeed and teach you nothing about the policy.

The free check will not run that test. It does not sign in, and it does not ask your database which tables have RLS enabled. Use it to see whether the service role key is sitting in the page you published. Use the Supabase table editor, and a session on the published site, to see whether RLS is actually on.

Check the published page.

No card needed. Free plan forever. Cancel anytime.