Skip to content
StackSiren

My Supabase service_role key is exposed. What now?

By StackSiren · Published

Treat it as fully compromised. A Supabase service_role or sb_secret_... key bypasses every Row Level Security policy, so anyone who has it can read and change your data as the elevated service_role Postgres role. Rotate the key immediately, then move every use into server-side or Edge Function code.

This is different from an anon or publishable key being in the browser. Those keys are designed to be public and still run under RLS. The secret and legacy service_role keys are not. Supabase's API keys guide is explicit: a secret key "bypasses every Row Level Security policy you have" and "must never leave your control".

Why service_role bypasses RLS

Every Supabase API key maps to a Postgres role:

Key Looks like Postgres role RLS applies?
Publishable sb_publishable_... anon or authenticated (with a user JWT) Yes
Legacy anon Long JWT eyJ... same as above Yes
Secret sb_secret_... service_role No (BYPASSRLS)
Legacy service_role Long JWT eyJ... service_role No

The service_role Postgres role has the BYPASSRLS attribute. Policies on your tables never run for it. Grants still apply, but a typical project grants that role full access, so the key is effectively a master key for the Data API.

Supabase is deprecating the legacy anon and service_role JWTs by the end of 2026 in favour of publishable and secret keys. Rotation is a good moment to migrate.

How to tell anon from service_role (legacy JWTs)

Publishable and secret keys are short strings with clear prefixes, so they're easy to tell apart. The two legacy keys both look like long eyJ... JWTs. Decode the middle segment on your own machine — don't paste keys into online JWT sites:

node -e 'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url").toString()))' "eyJhbGciOi..."
# { iss: "supabase", ref: "abcd1234", role: "anon", ... }          <- public by design
# { iss: "supabase", ref: "abcd1234", role: "service_role", ... }  <- rotate now

If the role claim is service_role, or the value starts with sb_secret_, stop and rotate before you dig into the rest of this guide.

Step 1: Rotate the compromised key

Supabase's docs cover rotating a leaked or compromised key. Summarised:

  1. Create a new secret key in Dashboard → Settings → API Keys.
  2. Replace the old key everywhere your backend uses it (Edge Functions, servers, cron jobs, CI secrets). If the leak was a legacy service_role JWT, replace it with the new sb_secret_... key.
  3. Confirm every component is on the new key (redeploy if needed).
  4. Retire the old one. Delete a secret key (that can't be undone). For a legacy service_role JWT, deactivate the legacy keys in the same Dashboard section instead — deactivation is reversible if you missed a client.

Don't put the new key anywhere the old one lived until you've fixed the cause. Also review Dashboard logs and table data for activity you don't recognise. We can't tell you what Supabase will do about abuse cases; contact their support if you need that conversation.

Newer secret keys refuse to work in a browser (Supabase matches on User-Agent and returns HTTP 401). That helps against accidental front-end use, but an attacker can still call the API from a script, so rotation is still required.

Step 2: Find how it leaked

Only check sites and repositories you own.

A. Front-end JavaScript bundle

Search your live assets for service_role, sb_secret_, and long eyJ strings near supabase:

SITE=https://yourapp.com
curl -s "$SITE/" | grep -oE '(/assets/[^"]+\.js|/_next/static/chunks/[^"]+\.js)' | sort -u | while read js; do
  curl -s "$SITE$js" | grep -qE '(sb_secret_[A-Za-z0-9_-]+|service_role)' && echo "Possible secret in $js"
done

In DevTools → Sources, search all files (Ctrl/Cmd + Shift + F) for service_role and sb_secret_. Common causes in AI-built apps: a VITE_ or NEXT_PUBLIC_ variable holding the secret, or a hard-coded key pasted into a client module. See are VITE_ variables secret? and is NEXT_PUBLIC_ safe?. Our exposed API key checker automates the bundle search on sites you own.

B. Public .env or .git

curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.env
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.git/config

Look at the body, not only the status: some SPAs return 200 with index.html for every path. See the exposed .env guide.

C. Source maps and Git history

curl -sI https://yourapp.com/assets/index-abc123.js.map | head -1
git log -p --all -S 'service_role' -- . ':(exclude)node_modules' | head
git log -p --all -S 'sb_secret_' -- . ':(exclude)node_modules' | head

Revocation (step 1) is what protects you; rewriting history is optional once the key is dead.

D. Pastes and shared projects

Screenshots, Discord/forum posts, shared Bolt/Lovable/Replit projects, CI logs that print env vars, and AI chat transcripts. Search for the key's prefix.

Step 3: Move every use to the server

The browser should only ever hold a publishable or anon key. Admin work belongs behind something you control.

Supabase Edge Function (common for Lovable and Bolt)

Prefer reading the injected secret from the function runtime rather than shipping a key in source. Supabase documents SUPABASE_SECRET_KEYS (JSON) for new keys, and still injects the legacy SUPABASE_SERVICE_ROLE_KEY for older projects — check the current Edge Functions environment variables for your stack:

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

Deno.serve(async (req) => {
  // Require a signed-in user before doing privileged work
  const userClient = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } } },
  );
  const { data: { user } } = await userClient.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}");
  const secret = secretKeys["default"] ?? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
  if (!secret) return new Response("Server misconfigured", { status: 500 });

  const admin = createClient(Deno.env.get("SUPABASE_URL")!, secret);
  // Example: privileged read the browser must not do directly
  const { data, error } = await admin.from("profiles").select("id, email").eq("id", user.id).maybeSingle();
  if (error) return new Response(error.message, { status: 500 });
  return Response.json({ data });
});

The front end calls supabase.functions.invoke(...) with the user's session. No secret leaves the function.

Next.js route handler / other servers

import { createClient } from "@supabase/supabase-js";
import "server-only";

const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!, // never NEXT_PUBLIC_ / VITE_
);

Add your own auth check before any privileged query. An open admin proxy is almost as bad as a leaked key.

Step 4: Prove the browser is clean

  1. Redeploy after removing every front-end reference.
  2. Re-run the bundle search from step 2A.
  3. As a signed-out visitor, hit a private table with only the publishable key — you should get [] or a permission error, never real rows. Our Supabase RLS checker covers the signed-out case. Full policy setup is in Setting up Supabase RLS in a Lovable app and the Supabase RLS guide.

FAQ

Is my anon / publishable key the same problem? No. Those are meant to be public. The risk with them is bad grants or RLS, not the key itself. A service_role or sb_secret_ key in the browser is a real leak.

I rotated the key but the app still works with the old one. Something is still holding the old value, or you haven't deactivated/deleted it yet. Check every environment (production, preview, Edge Function secrets, CI) and finish step 1.4.

Can I obfuscate the service_role key in the front end? No. The browser has to use it, so anyone can extract it. Move the work server-side.

Do I need to rotate the JWT secret as well? For a leaked API key, create and swap keys as above. Rotating the project's JWT secret is a separate, heavier step that invalidates existing user sessions; only do it if Supabase's incident guidance for your case says so, and expect signed-in users to need to log in again.

How do I stop this happening again? Never prefix secrets with VITE_ or NEXT_PUBLIC_. Keep admin clients in Edge Functions or route handlers. Run an external scan after deploys. StackSiren's first scan is free and passive; Deep Scan is $49 one-off or $99/mo as an add-on, and paid plans start at Solo $29/mo with scheduled rescans of sites you own.

Check your app now

Free passive scan, results in under a minute.

Run a free scan

Fix guides for this topic

Platform checklists: Lovable · Bolt · Replit · Cursor

Related posts

Sources