Skip to content

Guides

Supabase service role key exposed in your frontend JavaScript

Niall Hackett ·

If the key in your bundle is the anon key or an sb_publishable_... key, that's expected: it's built to be public, and Row Level Security (RLS) decides what it can read. If it's a service_role key or an sb_secret_... key, anyone who opens DevTools can read and write your whole database, because those keys bypass RLS. Move it server-side, then rotate it and turn off the old one. Removing it from the code isn't enough.

Which Supabase keys are safe to put in frontend code?

Supabase has four key types (Supabase API keys docs):

KeyLooks likeSafe in the browser?What it can reach
Publishablesb_publishable_...YesOnly what RLS allows (anon / authenticated roles)
anon (legacy)Long JWT starting eyJYesSame as publishable
Secretsb_secret_...NoEverything; bypasses RLS
service_role (legacy)Long JWT starting eyJNoEverything; bypasses RLS

The publishable/anon key is only safe if RLS is on for every table and your policies are tight (Securing your data). A public key plus a table with RLS off, or a USING (true) policy, is still a data leak.

Supabase is deprecating the legacy anon and service_role keys by the end of 2026, so it's worth moving to publishable and secret keys while you fix this (announcement on GitHub, migration guide).

Is this actually happening in AI-built apps?

Yes. In an r/lovable PSA, someone who scanned Lovable apps reported exposed Supabase credentials in client code, including some service_role keys. Another r/lovable roundup of common breaks lists "API keys leaking into the frontend" and notes the AI sometimes puts the service_role key there. It usually happens when you ask for an admin feature and the AI "fixes" an RLS error by swapping in the powerful key.

How do I tell which key is in my bundle?

Search your built JavaScript, not just your source. Vite and Next.js inline env vars at build time, so the deployed files are what matters.

Shell
# After `npm run build` (Vite outputs to dist/, Next.js to .next/static)
grep -rhoE "sb_(publishable|secret)_[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+" dist/ .next/static 2>/dev/null | sort -u

An sb_secret_ hit is a problem. For a JWT (eyJ...), decode the middle part and read the role claim:

JavaScript
// Paste in the browser console or run with Node 18+
const key = 'eyJ...paste-the-key...'
const payload = JSON.parse(atob(key.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
console.log(payload.role) // "anon" is fine, "service_role" is the leak

You can also check the live site: open DevTools, go to Sources, and search the loaded JS for service_role and sb_secret_.

Where should the service role or secret key live instead?

Only on code you control: a server route, a cron job, or a Supabase Edge Function. Name the env vars so your bundler won't ship them. Vite only exposes vars prefixed VITE_, and Next.js only exposes NEXT_PUBLIC_, so never give the secret key one of those prefixes (Supabase API keys docs):

Shell
# .env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...

# Server-only. No VITE_ / NEXT_PUBLIC_ prefix.
SUPABASE_SECRET_KEY=sb_secret_...
TypeScript
// src/lib/supabase.ts (browser): publishable 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
)

For an admin action, move it into an Edge Function that checks who's calling before it uses elevated access. Inside Edge Functions, Supabase injects the secret keys as a JSON map in SUPABASE_SECRET_KEYS:

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

Deno.serve(async (req) => {
  // 1. Who is calling? Use their JWT with the publishable/anon client.
  const userClient = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!, // or your publishable key
    { global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user || user.app_metadata?.role !== 'admin') {
    return new Response('Forbidden', { status: 403 })
  }

  // 2. Only now use the elevated key, server-side.
  const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
  const admin = createClient(Deno.env.get('SUPABASE_URL')!, secretKeys['default'])

  const { userId } = await req.json()
  const { error } = await admin.auth.admin.deleteUser(userId)
  return Response.json({ ok: !error, error: error?.message ?? null })
})

A nice side effect: new sb_secret_ keys return HTTP 401 when used from a browser (Supabase checks the User-Agent). That stops accidental client use, but an attacker can still use a leaked key from a script, so it's not a reason to skip rotation.

How do I rotate a leaked service role or secret key?

Follow Supabase's rotate a leaked key guide. In short:

  1. Fix the cause first. Remove the key from client code and redeploy, or the new key will leak the same way.
  2. Create a new secret key in Dashboard → Settings → API Keys.
  3. Swap it in everywhere your backend uses the old one: server env vars, Edge Function code, database webhooks, CI secrets.
  4. If the leak was a legacy service_role key, also switch your frontend from the anon key to a publishable key. Turning off the legacy keys disables both at once.
  5. Retire the old key. Delete a leaked sb_secret_ key, or deactivate the legacy keys. If the leak was service_role, Supabase also says to revoke the legacy JWT secret by moving to JWT signing keys.

If you see signs someone is already using the key, retire it right away, even though parts of your app will break until you finish the swap.

What should I check after rotating?

Assume the data was readable while the key was public. Make sure RLS is on for every table:

SQL
-- Tables in the public schema with RLS switched off
select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Then look for policies that let anyone read everything:

SQL
select tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or qual is null)
order by tablename;

Finally, test what the public key can reach from outside your app. An empty array or a permission error is what you want for private tables:

Shell
curl "https://your-project.supabase.co/rest/v1/profiles?select=*&limit=5" \
  -H "apikey: sb_publishable_..."

If the right users can't see their data after you tighten things, see Supabase RLS policy error and Supabase 42501 permission denied.

FAQ

Is it a problem that my Supabase anon key is visible in the browser?

No. The anon key and the publishable key are designed to be public. Your protection is RLS, so check that every table has it turned on with sensible policies.

I deleted the service_role key from my code. Am I safe now?

Not yet. Anyone who copied it can keep using it until you rotate it. Old builds, git history and caches may also still contain it. Create a new key, swap it in, then deactivate the old one.

Does the AI builder hide the key if it's in an env var?

Only if the variable has no public prefix. A key in VITE_... or NEXT_PUBLIC_... ends up in the JavaScript sent to every visitor.

Will switching to publishable and secret keys break my app?

Not if you do it in order. Both key systems work at the same time, so you can move clients one by one and turn off the legacy keys last (migration guide).

Do I need to rotate the anon key if it was public?

No, because it was meant to be public. You'd only replace it as part of moving off legacy keys, or because you're rotating a leaked service_role key.


Want a second pair of eyes on your live app? ReplayWatch runs a free scan of your deployed AI-built app. It flags secrets exposed in your HTML/JS, tests for public Supabase/Firebase reads, checks that the page loads, looks at security headers, finds broken links and assets, reviews SEO tags, and confirms your signup page is reachable. It doesn't log in, so data behind auth still needs the SQL checks above. ReplayWatch can then keep monitoring the app, so a key that slips back into a build gets caught early.