ANADI THAKUR
CONTENTS · FIXES · 6 MIN

FIXES · 6 MIN READ

THE FIX

Replace the leaked secret key in Supabase's API keys settings, update it on your server only, strip it from client code and git history, check the logs, then move the privileged work into an Edge Function and fix RLS so the public key is enough.

LAST VERIFIED

24 SEPT 2026

FIXES

Your Supabase service_role key is in your frontend: how to check, what to do first, and the real fix

USE WHENYour app talks to Supabase from the browser, and somewhere in its code or its environment variables there's a key with service_role or sb_secret_ in it, or you can't say for certain that there isn't.

Supabase gives you two kinds of key. The public one is meant to be in your JavaScript; the service_role (or sb_secret_) key skips every security rule on your database and must never leave a server. How it ends up in AI-built frontends, how to check your live site in five minutes, what to do in the first hour if it's there, and how to rebuild the feature so it never needs to be.

Every Supabase project has two kinds of API key. One is designed to be printed in your website's JavaScript for anyone to read. The other is the master key to your database, and if it's in your JavaScript too, anyone who opens your site can read, change or delete every row you have, and every user account with them.

Two keys, two very different jobs

The public key is called the anon key in older projects and the publishable key (starting sb_publishable_) in newer ones. A request made with it runs as a low-privilege role, and what that role can see is decided by row-level security, the rules inside your database. I've written up how RLS works and how to check yours; the short version is that the public key is an address, and RLS is the lock.

The secret key is called the service_role key in older projects, and in newer ones a secret key, starting sb_secret_. It exists for code you run on a server: admin scripts, background jobs, webhooks. A request made with it bypasses RLS entirely. Not "has more permissions". Bypasses. Every policy you wrote is simply not consulted. It can also reach Supabase's admin functions for user accounts, which means listing your users' emails, or deleting them, and it ignores the access rules on your Storage buckets too.

So if the secret key is in your frontend, it doesn't matter how good your RLS is. The lock is fine. You've handed out the master key.

How it ends up in the browser

I see three routes, and none of them requires anyone to do anything obviously wrong.

The prefix. Vite only puts environment variables into the browser code if their names start with VITE_; Next.js does the same with NEXT_PUBLIC_. So when a variable is undefined in the app, the quick fix is to add the prefix. VITE_SUPABASE_SERVICE_ROLE_KEY works immediately, and it works by pasting the key into the JavaScript every visitor downloads. (More on how those prefixes behave in works locally, breaks on Vercel.)

The AI tool "fixing" a permissions error. RLS is doing its job, and your app hits an error like new row violates row-level security policy. You ask the AI to fix it. The fastest change that makes the error go away is to swap the public key for the service_role key, and sometimes that's exactly what it does. The error disappears because the security disappeared. It's worth searching your code even if you never pasted the key yourself.

A committed .env file. The key was only ever meant for a server, but the .env file holding it got committed, and the repository is public, or becomes public later. Deleting the file in a new commit doesn't help: it's still in the history, and anyone can read that.

How to check, in five minutes

1. Search what your live site actually ships. Open your production site in Chrome, open the developer tools, and press Ctrl+Shift+F (Cmd+Option+F on a Mac) to search every loaded file. Search for sb_secret_. Then search for eyJ: the older keys are JWTs, and every JWT starts with those three characters. Note that searching for service_role itself won't find a legacy key, because the word is encoded inside it.

A cleaner test: copy your service_role key from Supabase's API keys settings, and search your site for its last ten or so characters. (Not the first ten. Every Supabase JWT starts with the same header, so the start of your anon key will match too.) If it's found, it's leaked.

2. Decode any JWT you find. A JWT is three chunks separated by dots, and the middle chunk is just encoded text, not encrypted. It says which role the key is for. Don't paste a key you suspect is secret into a website to decode it; that's one more place it's been. Decode it locally instead. In the browser console:

JS
const key = 'PASTE-THE-KEY-HERE';
JSON.parse(atob(key.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));

You'll see something like { iss: "supabase", ref: "abcd...", role: "anon", ... }. role: "anon" is the public key, and that's fine. role: "service_role" is the one that should never be there.

3. Check your git history, including anything you've deleted. From the project folder:

BASH
git log --all -p -S "service_role" --oneline
git log --all -p -S "sb_secret_" --oneline
git log --all --oneline -- .env .env.local .env.production

If the repository is or ever was public, anything those commits contain should be treated as published.

If you'd rather not do any of this by hand, the free Supabase check on this site does the outside-in version. It deliberately refuses a service_role or sb_secret_ key if you paste one (it will tell you to rotate it instead), and uses your public key to show you what that key can read.

What to do right now, in this order

If you found it, treat it as used. Keys in public JavaScript are picked up by automated scanners, so the question isn't whether someone could have copied it.

1. Replace the key. Do this first. Everything else can wait an hour; this can't. In your Supabase project, open the API keys settings.

  • If you're on the newer keys, create a new secret key, then delete the one that leaked. Secret keys are independent of each other, so this doesn't touch your publishable key or log anyone out.
  • If your project still uses the legacy JWT-based keys, the service_role key can't be replaced on its own: it's signed with your project's JWT secret, and so is the anon key. The cleanest route is to switch to the newer publishable and secret keys and then disable the legacy ones. Rotating the JWT secret instead also works, but changes your anon key and signs every user out, so plan for that.

Supabase also rejects the newer sb_secret_ keys on requests that look like they come from a browser, which is a useful safety net. It isn't protection: anyone who copied the key can use it from a server or a terminal.

2. Put the new key where it belongs, and only there. Update it in your server-side environment variables: your Edge Function secrets, your hosting provider's settings for server functions, your scripts. The variable name must not start with VITE_ or NEXT_PUBLIC_. Redeploy anything that uses it.

3. Remove it from the client code. Find every place the frontend creates a Supabase client and make sure it uses the public key. In Lovable projects that's usually src/integrations/supabase/client.ts. The features that relied on the secret key will now break. That's expected; the next section is how to rebuild them.

4. Clean the git history. Tools like git filter-repo or BFG Repo-Cleaner can rewrite history to drop the file or string, and then you force-push. Do it, but be clear about what it buys you: it stops the next person from finding the old key. It does nothing about anyone who already did. That's why rotation comes first.

5. Look at what it could have reached. Open the logs in your Supabase dashboard and read back as far as your plan keeps them. You're looking for things your app doesn't do: bulk reads of whole tables, unexpected deletes or updates, requests from IP addresses or user agents you don't recognise, changes to user accounts. If personal data was exposed, you may have obligations to tell your users; that depends on where you and they are, and is worth a proper answer rather than a guess.

The real fix: the browser never needs that key

The secret key in your frontend was doing one of two things: working around an RLS policy that was wrong, or doing something genuinely privileged that belongs on a server.

If it was working around RLS, fix the policy. "Users can't insert their own posts" almost always means there's no insert policy, or its with check doesn't match what the app sends. Write the policy that says exactly who may do what, and the public key is enough. The RLS post has the patterns.

If it's genuinely privileged, an admin screen, sending a user's data to another service, deleting an account, move it into a server-side function: a Supabase Edge Function, a Vercel or Next.js API route, anything that runs where the visitor can't read the code. The browser asks; the server checks; the server acts. The checking is the part that matters:

TS
SHOW ALL LINES
// supabase/functions/admin-orders/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';

Deno.serve(async (req) => {
  // The secret key lives here, in the function's secrets, never in the browser.
  const admin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
  );

  // 1. Who is asking? Read it from their login token, not from the request body.
  const token = req.headers.get('Authorization')?.replace('Bearer ', '') ?? '';
  const { data: { user } } = await admin.auth.getUser(token);
  if (!user) return new Response('Not signed in', { status: 401 });

  // 2. Are they allowed to do this? Admins live in a table users can't write to.
  const { data: isAdmin } = await admin
    .from('admins').select('user_id').eq('user_id', user.id).maybeSingle();
  if (!isAdmin) return new Response('Not allowed', { status: 403 });

  // 3. Only now do the privileged work.
  const { data, error } = await admin.from('orders').select('*');
  if (error) return new Response(error.message, { status: 500 });
  return Response.json(data);
});

The frontend calls it with supabase.functions.invoke('admin-orders'), which sends the signed-in user's token along automatically. If you're on the newer keys, check which name your function's secret key is stored under and use that.

A function that skips steps 1 and 2 hasn't fixed anything. It's the same leaked key with a URL in front of it.

How you know you're done

Search your live site again: no sb_secret_, and every eyJ you find decodes to role: "anon". The old key is deleted or disabled in Supabase. Your git history is clean. And the features that used to need the secret key either work with the public key and a correct policy, or call a function that checks who's asking before it does anything.

The key swap takes minutes. The reason the key was there in the first place, usually a policy nobody wrote, is the thing to fix.

READ NEXT