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:
- Create a new secret key in Dashboard → Settings → API Keys.
- Replace the old key everywhere your backend uses it (Edge Functions, servers, cron jobs, CI secrets). If the leak was a legacy
service_roleJWT, replace it with the newsb_secret_...key. - Confirm every component is on the new key (redeploy if needed).
- Retire the old one. Delete a secret key (that can't be undone). For a legacy
service_roleJWT, 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
- Redeploy after removing every front-end reference.
- Re-run the bundle search from step 2A.
- 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.
Related reading
- I leaked my OpenAI API key. What now?
- Are VITE_ environment variables secret?
- Is NEXT_PUBLIC_ safe for API keys?
- Bolt + Supabase RLS checklist
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.
Fix guides for this topic
Platform checklists: Lovable · Bolt · Replit · Cursor