Supabase

Supabase service_role key exposed in your frontend: what to do

A service_role or sb_secret key in browser code bypasses row level security. How to confirm the leak, rotate the key and move the call to the server.

Updated 2026-10-27

Supabase gives every project keys with very different powers. The anon key (or the newer sb_publishable_ key) is meant to sit in your browser code and relies on row level security to limit what it can do. The service_role key (or the newer sb_secret_ key) bypasses row level security entirely. Anyone holding it can read, change and delete every row in your project through the API.

AI coding tools mix these up because both are "the Supabase key" in a prompt. A typical path: the app throws a permission error, the assistant suggests the service key as the fix, and the key ends up in a client file or in a variable with a public prefix.

How to tell which key you have

Newer projects make it easy: sb_publishable_ is public, sb_secret_ is private. Older projects use two JWTs that look alike. Decode the middle part of the JWT (between the dots) and read the role field:

echo 'PASTE_ONLY_THE_MIDDLE_PART_HERE' | base64 -d 2>/dev/null; echo

"role":"anon" is the public key. "role":"service_role" is the dangerous one. Do not paste a real key into an online decoder.

Find where it leaked

Check the built app that visitors download, not only your source files.

  1. Open the deployed app, DevTools, Sources (or Network), and search all files for service_role, sb_secret_ and SERVICE_ROLE.
  2. In your project, search for the same strings and for anything that starts with eyJ.
  3. Look at environment variables with public prefixes: VITE_, NEXT_PUBLIC_, PUBLIC_, EXPO_PUBLIC_. Anything with those prefixes is compiled into the browser bundle. A secret there is published.
  4. Check Git history, .env files that were committed, source maps, screenshots, and chat or prompt history where you pasted the key.
  5. Check logs and error tools for request headers.

Run the free Vibe Scan on your URL as a fast outside check. It reads the public page and scripts the way a browser does and flags key patterns without storing them in plain text.

Fix it in this order

  1. Rotate the key first. Removing it from the code does not help while the old key still works. In the Supabase dashboard, open the project's API keys settings, create a new secret key, and disable or delete the leaked one. Follow Supabase's guide "Migrating to publishable and secret API keys" if you are still on the legacy keys. Check the dashboard text before you click: rotating the legacy JWT secret changes the anon key too.
  2. Remove the key from the client. Delete it from source, env files with public prefixes, and the build settings of your host.
  3. Move the privileged call to a server. Use a Supabase Edge Function, a Next.js route handler, or any backend you control.
  4. Redeploy and re-check the bundle. Search the new build output again.
  5. Look for damage. Check your tables and storage for rows you did not create, and review the project's API and auth logs.

Server-side pattern

The browser sends the user's session. The server verifies who they are, checks they may do this action, and only then uses the secret key.

// supabase/functions/delete-project/index.ts (Edge Function)
import { createClient } from "npm:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
  const userClient = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
  const { data: { user } } = await userClient.auth.getUser(token);
  if (!user) return new Response("Unauthorized", { status: 401 });

  const { projectId } = await req.json();
  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

  // Authorize explicitly. The admin client ignores RLS.
  const { data: project } = await admin.from("projects").select("owner_id").eq("id", projectId).single();
  if (project?.owner_id !== user.id) return new Response("Forbidden", { status: 403 });

  await admin.from("projects").delete().eq("id", projectId);
  return new Response("ok");
});

In the browser, call it with supabase.functions.invoke("delete-project", { body: { projectId } }). The service key lives only in the function's secrets.

The usual reason people reached for it

If the service key "fixed" a permission error, the real problem is a missing policy. Write a row level security policy that names who may do the action, for example using (auth.uid() = user_id), and the anon key will work. The Supabase RLS guide walks through the policies. The Lovable-specific version is in the Lovable and Supabase guide.

Keep it from happening again

FAQ

Is the anon key also a secret?

No. It is designed to be public, but only safe when row level security is on for every table and policies are correct.

I removed the key from my code. Am I safe?

Not until you rotate it. Anyone who loaded the old bundle, or a cached copy, may still hold a working key.

Can I use the service_role key in a mobile app?

No. A mobile app binary is public too. Put privileged operations behind a server function.

How do I know if someone used the key?

Check the project's API and auth logs for unusual activity and look for unexpected rows. If you cannot rule it out, tell affected users as the law where you operate requires.