Skip to content
All guides

Supabase service_role key exposed in frontend code: how to fix it

By Obscura Security · Updated · Supabase, Lovable, Bolt, Cursor, Next.js, Vite

Short answer

The service_role key (called a secret key, starting sb_secret_, in newer projects) bypasses Row Level Security completely. If it is in your frontend code, your bundle or a public repo, treat it as stolen: create a replacement, delete the old key, and use the new one only in server code such as an edge function or API route.

If you searched your code or your deployed site and found a Supabase key with service_role in it, or one starting sb_secret_, that key gives whoever holds it full control of your database.

Why this is so serious

Row Level Security (RLS) is what protects your tables from people using your public key. The service_role key skips RLS entirely. With it, a stranger can read every user’s data, change or delete any row, create users and, depending on your setup, read your storage files.

The key usually ends up in the frontend in one of three ways:

  • An AI tool hit an RLS error and “fixed” it by creating the Supabase client with the service_role key.
  • The key was put in a VITE_ or NEXT_PUBLIC_ environment variable, which copies it into the public JavaScript bundle.
  • A .env file with the key was committed to a public GitHub repo.

Bots scan public repos and websites for these keys all the time, so assume that a key found in either place has already been seen.

How to check your own app

Do these against your own project only.

  1. Search your code. From the project folder:
grep -rnE "service_role|sb_secret_|SERVICE_ROLE" . --exclude-dir=node_modules --exclude-dir=.git
  1. Search your git history, because a key you deleted last week is still in old commits:
git log -p -S "service_role" --all | head -n 100
git log -p -S "sb_secret_" --all | head -n 100
  1. Search what visitors actually receive. Open your live site, open the browser developer tools, go to the Sources or Network tab and search all files for service_role and sb_secret_. Or check your build output folder (dist, build or .next/static) with the same grep.

  2. Decode any long key that starts eyJ. Paste the middle section (between the two dots) into a base64 decoder. If you see "role":"service_role", it’s the dangerous one. A key showing "role":"anon" is the normal public key.

The fix

1. Rotate the key first

Don’t wait until the code is tidy. In the Supabase dashboard, open Project Settings → API Keys.

  • Newer projects (secret keys, sb_secret_): create a new secret key, put it in your server’s environment, then delete the exposed one.
  • Legacy projects (the service_role JWT): switch to the new API keys and disable the legacy ones, or rotate the project’s JWT secret, which invalidates the old key. Rotating the JWT secret also changes your anon key and signs users out, so update your frontend afterwards.

Once the old key is deleted or disabled, copies of it stop working.

2. Remove it from the frontend

Create the browser client with the public key only:

import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY // or the anon key
)

Delete the variable holding the secret from your frontend .env, your hosting platform’s public variables, and any Lovable or Bolt project secrets used by client code.

3. Do privileged work on the server

A few jobs genuinely need full access, such as sending a welcome email on signup or running an admin report. Put those in a Supabase Edge Function. Supabase makes SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY available inside edge functions as environment variables, so the key never goes in your code. Check the caller first:

import { createClient } from 'npm:@supabase/supabase-js@2'

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

  // Who is calling? Use their token, not the admin key.
  const userClient = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response('Unauthorised', { status: 401 })

  // Only now use full access, for one specific job.
  const admin = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )
  const { error } = await admin.from('welcome_emails').insert({ user_id: user.id })
  if (error) return new Response('Failed', { status: 500 })

  return new Response('ok')
})

In Next.js, do the same in a route handler or server action, and keep the key in a variable with no NEXT_PUBLIC_ prefix, such as SUPABASE_SERVICE_ROLE_KEY. Import that client only in server files.

4. Fix the RLS problem that caused it

If the key was added to dodge an RLS error, the error is still there. Write the policy the query needs (see Supabase RLS disabled).

5. Check for damage

Look through your tables for rows you don’t recognise, check Authentication for unfamiliar users, and review the API logs in the dashboard for the period the key was exposed. If personal data was readable, work out whose it was.

Mistakes to avoid

  • Hiding the key instead of rotating it. Obfuscating or splitting the string in your code changes nothing, because the browser still has to use it.
  • Deleting the commit and force-pushing. Forks, clones and caches may still hold it. Rotate.
  • Putting the admin client in a shared file. If one file creates both clients and the frontend imports it, the secret can end up in the bundle again. Keep the admin client in a file only server code imports.
  • Using the service_role key in an edge function without checking who is calling. Anyone can call a public function. Verify the user first, as in the example above.

Common questions

What is the difference between the anon key and the service_role key?
The anon key (the publishable key, sb_publishable_, in newer projects) is meant to be in your frontend and is limited by RLS. The service_role key (the secret key, sb_secret_) skips RLS, so it can read, change and delete everything in your database. It must never leave your server.
I deleted the key from my code. Is that enough?
No. Anyone who loaded your site or cloned your repo earlier may already have it, and git history keeps old versions of files. Rotate the key so the old one stops working.
Why did my AI tool put the service_role key in the frontend?
It was probably fixing an error caused by RLS blocking a query, and the quickest way to make the error go away is a client that ignores RLS. The right fix is a policy, or a server-side call for the few things that really need full access.
Can I put the service_role key in a VITE_ or NEXT_PUBLIC_ variable?
No. Those prefixes tell the build tool to copy the value into the JavaScript sent to every visitor. A secret key needs a variable with no such prefix, read only in server code.

Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].