Supabase service_role key exposed: what to do now

By the RowLocked team · Updated

The Supabase service_role key, and its replacement the sb_secret_... secret key, is the master key to your project. It bypasses Row Level Security completely. Anyone who has it can read, change or delete every row in every table, list and download every file in Storage, and manage users through the Auth admin API.

So if it ended up in your frontend JavaScript, a public GitHub repo, a screenshot or a chat log, treat it as compromised. This guide walks through confirming the leak, rotating safely, and moving the key to where it belongs.

How to tell which key you are looking at

Supabase currently has two generations of API keys, and both may be active in your project:

  • Publishable key (sb_publishable_...) or the legacy anon key: low privilege, respects RLS, designed to be public. Seeing this in your frontend is normal. See Is the Supabase anon key safe to expose?
  • Secret key (sb_secret_...) or the legacy service_role key: elevated privilege, bypasses RLS, must never leave your servers.

Both legacy keys are long JWTs starting with eyJ, so they look alike. To tell them apart, paste the key into a JWT decoder you trust (or decode the middle segment locally with base64 -d) and look at the role claim: "role": "anon" is the public key, "role": "service_role" is the dangerous one. Decoding a JWT does not send it anywhere if you do it locally:

# Prints the payload of a JWT. Run locally; do not paste secrets into random websites.
echo 'PASTE.JWT.HERE' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

Supabase has announced that the legacy anon and service_role keys are being deprecated in favour of publishable and secret keys, so new projects and tutorials increasingly use the sb_ format.

Where leaked service keys usually come from

In AI-built apps, the same few patterns show up again and again:

  • The AI tool needed to do something RLS blocked (an admin page, a signup trigger, a cron job), and "fixed" it by creating a second Supabase client with the service key in the browser.
  • The key was put in an environment variable with a public prefix such as VITE_SUPABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_SUPABASE_SERVICE_KEY. Build tools inline these into the client bundle. See VITE_ environment variables are public.
  • A .env file was committed to a public GitHub repository, or served from the site root.
  • The key was pasted into a prompt or support chat that is later shared.

Step 1: Confirm whether it is public

Check your live site, not just your code, because the deployed bundle is what attackers see.

  1. Open your site, then open developer tools (F12) and go to the Sources or Network tab.
  2. Search across all loaded scripts (Ctrl+Shift+F or Cmd+Option+F in Chrome) for service_role, sb_secret_, and SERVICE.
  3. If you find a JWT, decode it as above to check the role claim.
  4. Search your repository and its history: git log -p | grep -n "service_role\|sb_secret_". A key deleted in a later commit is still in history.

RowLocked's free scan does the bundle search for you and masks anything it finds in the report.

Step 2: Fix the root cause before rotating

Supabase's own guidance is to fix how the key leaked before rotating; otherwise the new key leaks the same way. That means removing every use of the service key from code that runs in the browser.

Find what the browser was using the key for. Usually it is one of:

  • Reading or writing data that RLS blocked. Write a proper RLS policy instead. See Supabase RLS policy examples.
  • An admin action (deleting users, changing roles). Move it to an Edge Function that checks the caller is an admin.
  • A background task (sending emails, syncing data). Move it to an Edge Function, a database trigger, or a scheduled job.

Here is a minimal Supabase Edge Function that performs an admin action safely. The secret key lives in the function's environment, and the function verifies the caller before doing anything:

// supabase/functions/admin-delete-user/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const authHeader = req.headers.get("Authorization") ?? "";

  // Client bound to the caller's own JWT: respects RLS, used only to identify them.
  const asCaller = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
    global: { headers: { Authorization: authHeader } },
  });
  const { data: { user } } = await asCaller.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  // Check the caller is an admin using your own table (protected by RLS).
  const { data: me } = await asCaller.from("profiles").select("is_admin").eq("id", user.id).single();
  if (!me?.is_admin) return new Response("Forbidden", { status: 403 });

  // Only now use the elevated key, server-side.
  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
  const { userId } = await req.json();
  const { error } = await admin.auth.admin.deleteUser(userId);
  if (error) return new Response(error.message, { status: 400 });
  return new Response("ok");
});

SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY are provided to Edge Functions by default. If you create a new secret key, add it as a function secret with supabase secrets set and read that instead.

Also make sure the is_admin column cannot be changed by the user themselves, or the check is meaningless. An update policy with a with check that blocks changes to is_admin, or keeping roles in a separate table that only the server writes, takes care of that.

Step 3: Rotate the key

How you rotate depends on which kind of key leaked.

If a new-style secret key (`sb_secret_...`) leaked:

  1. In the dashboard, go to Settings, then API Keys, and create a new secret key with a descriptive name (for example edge-functions-2026-10).
  2. Replace the old key everywhere it is used: Edge Function secrets, your server's environment variables, CI, cron jobs, webhook handlers.
  3. Redeploy those services and confirm they work with the new key.
  4. Delete the compromised secret key. Deletion is permanent, so do it only after step 3.

If the legacy service_role JWT leaked:

  1. Create the new publishable and secret keys if your project does not have them yet (Settings, API Keys). This does not affect existing keys.
  2. Move every backend component from the service_role key to the new secret key, and every client from the anon key to the publishable key.
  3. Once nothing uses the legacy keys, deactivate them in the same settings screen. Supabase notes this is reversible if you find a client you missed.

Older advice says to "regenerate the JWT secret". On projects using the legacy setup that invalidates every key and every signed-in session at once, which causes downtime. The newer API keys exist largely so you can rotate one key without that blast radius.

Step 4: Check whether it was used

Rotation stops future misuse but does not tell you what already happened. Spend a few minutes looking:

  • Review the API and Auth logs in the dashboard (Logs Explorer) for unusual volumes of requests, requests from unfamiliar IPs or user agents, and bulk reads of sensitive tables.
  • Look for unexpected rows, changed records, new users with elevated roles, or new or deleted Storage files.
  • If personal data may have been accessed, you may have notification obligations depending on where you and your users are. Take that seriously rather than hoping it was not used.

Step 5: Keep it from leaking again

  • Only ever name the secret key with server-only variable names, for example SUPABASE_SECRET_KEY, never with VITE_, NEXT_PUBLIC_, EXPO_PUBLIC_ or REACT_APP_ prefixes.
  • Add .env* to .gitignore and enable GitHub secret scanning and push protection on your repository.
  • Use one secret key per backend component with the new key system, so a leak only forces one rotation.
  • Tell your AI tool the rule explicitly. A prompt you can paste:
Never use the Supabase service_role or secret key in any code that runs in the browser. If an operation is blocked by RLS, either write a correct RLS policy or move the operation into a Supabase Edge Function that verifies the caller first. Show me every place a Supabase key is used and which key it is.
  • Scan the live site after every deploy. Keys tend to come back when an AI tool rewrites a file.

Frequently asked questions

Is the service_role key the same as the anon key?

No. Both legacy keys are JWTs, but the anon key has the anon role and respects Row Level Security, while the service_role key bypasses RLS completely. Only the anon (or publishable) key belongs in a browser.

Can I restrict the service_role key to certain domains or IPs?

Not in a way that makes it safe to expose. Treat it like a database root password. The newer secret keys refuse requests that look like they come from a browser, which helps catch mistakes, but that is a guard rail, not a reason to ship them to clients.

My service key was only in a private repo. Do I still need to rotate?

If the repository has always been private and only trusted people have access, the risk is lower, but rotate anyway if the repo was ever public, forked, shared with contractors, or connected to third-party tools you no longer use.

Will rotating the key log out my users?

Rotating a secret key or deactivating legacy API keys does not by itself sign users out. Rotating the legacy JWT secret does invalidate existing sessions, which is one reason to use the newer API keys.

Related guides

All guides

Check my app now