Two Supabase keys, only one belongs in the browser
Supabase hands your project two API keys, and they are not interchangeable. The anon key (now also called the publishable key) is designed to ship in the browser. The service_role key is a privileged server key that bypasses Row Level Security entirely. The concrete launch risk is Supabase service_role key frontend exposure: shipping the second key where the first belongs, so a service_role key sits in your client bundle, downloaded by every visitor, ignoring every RLS policy you wrote.
The anon key is safe in the frontend for a specific reason. On its own it is just an identity token for the anonymous role, and RLS decides which rows that role can touch. The browser needs it to talk to Supabase, so it is public by design. The mechanics of how RLS constrains the anon key — policies, the anon role, deny-all — are covered in the Lovable and Supabase RLS post; this note is about the distinction between the two keys and how to make sure only the right one is exposed.
The service_role key is a different category of thing. It is meant for trusted server code, and Supabase grants it the postgres-level ability to read and write every row in every table, with RLS skipped. There is no policy that limits it. So if it reaches the browser, the careful per-table RLS work does not matter — the key in the bundle overrides all of it.
- The anon key in the bundle is the integration working as designed; RLS still stands in front of the data.
- The service_role key in the bundle is a leaked master key; it answers as a fully trusted caller and ignores RLS completely.
- This is the difference between an authenticated public role bounded by policy and a privileged role bounded by nothing.
Why an AI scaffold ships the wrong key
AI coding tools wire up a Supabase client to make a feature work on the first try. When a prompt needs a write that the anon role cannot do under RLS — an admin action, a server-side insert, a webhook handler — the fast path that always works is the service_role key, because it skips RLS and never hits a permission error. The generated code grabs it from an environment variable so it does not look hardcoded, which feels correct.
The trap is the prefix. In a Vite or Next.js frontend, only variables with a client-exposed prefix are available in browser code, so the scaffold reads something like VITE_SUPABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. Those exact prefixes are the ones the bundler inlines into the public JavaScript. The feature works, the demo passes, and the privileged key is now baked into every download. The general mechanics of how client-prefixed env vars end up in the bundle are covered in the note on Vite env variables exposed in the client bundle.
- The service_role key makes the broken write succeed, so it looks like the fix rather than the problem.
- A client-exposed prefix is what gets it into the bundle; the same key in a server-only variable would have been fine.
- Nothing in the running app reveals the leak — the feature behaves identically whether the key is privileged or not.
Tell the two keys apart, then search your bundle
You can confirm which key shipped without any attack, on your own app. Two checks together are reliable: identify the key by its JWT payload, then look for it in what you serve.
Both keys are JWTs, so decode the role claim. Take the key string, copy the middle segment between the two dots, and base64-decode it (any offline JWT decoder, or paste into a decoder you trust for your own non-production key). The decoded payload carries a role field. A value of anon is the publishable key and is expected in the browser. A value of service_role is the privileged key and must never appear in client-served code.
Then search the public surface for it. Open the deployed app in a clean browser session, open DevTools, and search the loaded JavaScript (the Network tab sources, or a saved copy of the bundle) for the strings service_role and supabase.co. You can also look for the literal key value if you know it. A service_role JWT, or the service_role string inside a key your client downloads, is a launch blocker.
- Safe result: every Supabase JWT reachable from the browser decodes to role: anon.
- Suspicious result: any reachable JWT decodes to role: service_role. Treat it as exposed.
- Check the built output, not your source. The leak is in what bundling produced and what the server actually serves, which is what a visitor downloads.
- Grep your environment config for any VITE_ or NEXT_PUBLIC_ variable name containing service or service_role. A privileged key behind a client-exposed prefix is the pattern to catch.
Move service_role to the server, and rotate if it leaked
The fix is about where the key lives, not which key you use. The service_role key belongs only in server-side code that the browser never receives: a backend route, a serverless or edge function, or a server environment variable with no client-exposed prefix. Anything that needs RLS-bypassing power runs there and returns only the result to the client, so the key never travels to the browser.
If a service_role key did ship to the bundle, rotate it immediately. Because it bypasses RLS, anyone who pulled it from your public JavaScript holds read and write access to every table regardless of your policies, and tightening RLS does not revoke a key that ignores RLS. Roll the key in the Supabase dashboard, deploy the build that no longer ships it, and confirm the old value no longer appears in what you serve.
- Keep the service_role key in a server-only env var (no VITE_, NEXT_PUBLIC_, or other client prefix) and read it only in server code or edge functions.
- In the browser, use the anon key and let RLS enforce access. If an action fails under RLS, the answer is a policy or a server endpoint, not the service_role key in the frontend.
- For privileged work the frontend triggers, call a server function that holds the service_role key, rather than handing the key to the client.
Rotating the key closes future access with the leaked value. It does not undo what may have happened while the key was public. Because service_role bypasses RLS and audit assumptions, treat any prior exposure as a possible data-access incident, not just a config typo to quietly patch.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check fetches your public JavaScript bundle and looks for secret-shaped strings, including Supabase service_role JWT patterns — a key whose decoded role is service_role appearing in client-served code. Because that key overrides every RLS policy, the scan flags it as a direct launch blocker with severity and fix direction, so it surfaces in your own check before it surfaces in someone else's. If it fires, rotate the key first, then ship the build that no longer ships it.
What it does not do is read your server code or judge how the service_role key is used once it is server-side. Confirming that privileged operations run only on the server, and that your RLS policies match your intent, is manual review of your code and data model. Launch Check is not a penetration test, a code review, or a guarantee that the app is secure.
A clean bundle scan means no service_role-shaped key is in the public JavaScript it fetched — not that every secret is handled correctly. The scan sees what is reachable from outside; it does not read your server environment or confirm where the key actually lives. Next step: decode every Supabase key your browser can reach, confirm each one is role: anon, move any service_role key to server-only env, and run a focused Launch Check against the public URL before you share it.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.