Supabase · Security ·
Supabase Service Role Key Exposed? How to Check, Rotate and Stop It Happening Again
Most Supabase keys are meant to be seen. The service role key is not. If yours has ended up in a browser bundle, a public repo or a log file, anyone who finds it can read and write every row in your database, whatever your Row Level Security policies say.
This guide pairs with Ship-Ready AI Rules, and if you want the next step, read Supabase “Permission Denied for Table”: the Data API Grants Change Scheduled for 30 Oct.
This guide covers what the key actually does, how it typically leaks from a Next.js app, how to check whether you're exposed, how to rotate it the way Supabase recommends, and how to stop it happening again. Every Supabase-specific claim below comes from Supabase's API keys documentation and changelog (both checked 14 October 2026).
Why the service role key is the one key that must never leak
Supabase gives every project two kinds of key:
| Key | Format | Where it belongs | What it can reach |
|---|---|---|---|
Publishable key (legacy: anon) |
sb_publishable_... |
Anything you ship: browser, mobile app, CLI | Only what your RLS policies allow |
Secret key (legacy: service_role) |
sb_secret_... |
Servers, Edge Functions, cron jobs you control | Everything. It bypasses RLS |
Secret keys work through the built-in service_role Postgres role, which has full access to your project's data and the BYPASSRLS attribute. In Supabase's own words, RLS policies "never apply to a secret key". So the usual advice "RLS will protect you" doesn't apply here. RLS protects you from the publishable key. Nothing in the database protects you from a leaked secret key.
Two details are worth knowing:
- Naming is changing. Supabase is deprecating the legacy
anonandservice_roleJWT keys by the end of 2026 in favour ofsb_publishable_andsb_secret_keys. If a tutorial (or your AI assistant) tells you to paste a long key starting witheyJ, it was written for the legacy keys. Everything in this post applies to both the legacyservice_rolekey and the new secret keys. - New secret keys have one extra guard. Supabase rejects a secret key used from a browser, matching on the
User-Agentheader and returning401. Don't treat that as protection. The docs are explicit that an attacker can still use the key from other tools.
How it usually leaks in a Next.js app
In Next.js and Supabase projects, a leak almost always comes from one of five routes.
- A
NEXT_PUBLIC_prefix. Next.js inlines anyNEXT_PUBLIC_variable into the JavaScript bundle at build time, soNEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEYis shipped to every visitor. The only key that should carry that prefix is the publishable one (Supabase's example usesNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY). - An admin client imported into client code. A shared
lib/supabase.tsthat creates both clients gets imported by a"use client"component, and the secret goes with it. - A committed
.envfile, or a key pasted into a CI script, a README or a config file. - Logs and error messages. A key printed in a debug log or returned in an API error is as public as wherever those logs go. Supabase says not to log keys at all, "even one that looks invalid".
- An AI agent's "fix". A query returns no rows because of an RLS policy, and the agent swaps in the admin client to make the error go away. It works, so it ships.
Check in 10 minutes: are you exposed?
Run these from your project root. Look for both sb_secret_ and long eyJ tokens, plus your variable names.
1. The built JavaScript. Build, then search what the browser receives:
npm run build
grep -rEl "sb_secret_|SERVICE_ROLE|SECRET_KEY" .next/static || echo "nothing in client bundles"
Legacy keys are JWTs, so also search for them. The anon key is a JWT too and is meant to be public, so for each match, decode the middle (payload) segment and check its role claim. Only "role":"service_role" is a problem:
grep -rEoh "eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+" .next/static | sort -u
Then open your deployed site, view the loaded scripts in browser devtools, and search there too.
2. Your environment variable names:
grep -rn "NEXT_PUBLIC_.*\(SECRET\|SERVICE\)" --include="*.ts" --include="*.tsx" --include=".env*" .
3. Git history, not just the current tree. A key deleted in a later commit is still in history:
git log -p --all -S "sb_secret_" | head
git log -p --all -S "service_role" | head
git log -p --all -G "eyJ[A-Za-z0-9_-]{10,}\.eyJ" | head
git ls-files | grep -E "(^|/)\.env"
If the repo has ever been public, or shared with anyone who shouldn't have full database access, assume the key is compromised.
4. Logs. Search your hosting and error-tracking logs for the key prefix.
For the rest of a pre-launch review (RLS on every table, webhook signatures, auth checks), work through the free Supabase + Stripe security checklist.
If it leaked: rotate and contain
Supabase's documented procedure for a leaked secret or service_role key starts with a warning: don't rush, and fix the root cause first, or the new key will leak the same way. Then:
- Create a new secret key in the Dashboard under Settings > API Keys.
- Replace the compromised key everywhere your application uses it: hosting environment variables, Edge Functions, CI secrets, cron jobs. If the leaked key was a legacy JWT
service_rolekey, replace it with the new secret key rather than another legacy one. - Confirm every component now uses the new key. Redeploy and watch for errors.
- Retire the old key. Deleting a secret key can't be undone, so only do it after step 3. Legacy
anonandservice_rolekeys are deactivated in the same Dashboard section instead, and deactivation is reversible if you find a client you missed.
Creating new keys doesn't revoke the legacy ones. Both systems work side by side until you disable the legacy keys, so step 4 isn't optional.
Then contain the damage:
- Review the data. Look for unexpected inserts, updates, deletes and new users from the exposure window, and check Storage buckets.
- Clean the history if you like, but treat the key as burned either way. Rewriting git history doesn't un-leak a key someone already copied.
- Run the Security Advisor in the Dashboard. Supabase's built-in checks for common role and RLS problems are a good sanity pass after an incident.
Heads-up: the Data API grants change scheduled for 30 October 2026
There's a related Supabase change to know about. Supabase's changelog announcement schedules a change for 30 October 2026: existing projects would stop automatically granting select, insert, update and delete on new tables in the public schema to anon, authenticated and service_role. The changelog lists 30 May 2026 as the start of a gradual rollout of this default to new projects, but a Supabase CLI maintainer noted in August (PR #6337, which reversed the CLI's earlier local default change in PR #5524) that newly created hosted projects were still auto-granting. Check the changelog before relying on these dates.
If the change goes ahead as scheduled, what it means in practice:
- Existing tables keep their grants and stay reachable. Only tables created after the change reaches your project are affected.
- A new table without an explicit grant is invisible to the Data API (REST, GraphQL and supabase-js). Requests fail with error 42501 "permission denied for table", and the response includes a hint naming the missing grant.
- That includes the secret key. service_role loses its default grant on new tables too, so "switch to the admin client" won't fix a 42501. The right fix is a minimal grant in the same migration that enables RLS.
- RLS behaviour doesn't change, and direct Postgres connections (psql, an ORM, a connection string) aren't affected.
For SQL to check your project, the migration fix and an FAQ, see Supabase "permission denied for table" after 30 October: the Data API grants change.
Prevent it: the server-only pattern
The fix is structural: make it impossible for the admin client to reach the browser.
// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!, // no NEXT_PUBLIC_ prefix, ever
{ auth: { persistSession: false } }
);
}
import "server-only"makes the build fail if a Client Component imports this file. It's the package Next.js documents for exactly this case.- Keep the admin client in its own module, separate from the user client, so one import can't drag the secret along.
- Use it only where there's no user to act for: verified webhooks, cron jobs, trusted background work. Everywhere else, use the publishable key with the user's session and let RLS do its job.
- Never use the admin client to get past an RLS error. If a query returns nothing, the policy is the bug. Fix the policy.
- Use one secret key per backend component, as Supabase recommends, so a leak in one place means rotating one key, not all of them.
Stop your AI agent reintroducing it
Leak route 5 is the one that recurs. An agent will happily undo your careful setup the next time it hits an empty query. Put the rule where the agent reads it, in AGENTS.md or .cursor/rules:
## Supabase keys (always)
- Secret/service_role key: only in lib/supabase/admin.ts (import "server-only").
- Never prefix a secret with NEXT_PUBLIC_. Never log keys.
- Admin client only in verified webhooks, cron and trusted jobs.
- Empty result or RLS error? Fix the policy. Never switch to the admin client.
Disclosure: Ship-Ready AI Rules and Launch Audit are our own paid products.
Ship-Ready AI Rules packages this rule with the rest of the Next.js + Supabase + Stripe set for $19:
- Rule formats: Cursor rules, AGENTS.md and Claude Code skills.
- Guardrail hooks designed to block secrets in client components and NEXT_PUBLIC_*SECRET variables before the agent writes them. They reduce risk; they don't replace code review.
- A reference app the rules were checked against.
There's also a free sample on GitHub if you want to try the approach first.
Already built the app? Run Launch Audit before you launch. It scans for service keys in client components, secrets in NEXT_PUBLIC_ variables and .env files in git, along with RLS, auth and Stripe webhook problems, and writes a report with file:line fixes.
FAQ
Is the publishable (anon) key safe to expose? Yes, by design. Supabase says it's safe to put in web pages, apps and source code. It only reaches what your RLS policies allow, so its safety depends on RLS being enabled on every table with correct policies.
Does RLS protect me if the service role key leaks?
No. The service_role role has the BYPASSRLS attribute, so policies never apply to it. Rotation is the only fix.
Do I need to rewrite git history? It's good hygiene, but it isn't the fix. Rotate first. Once a key has been pushed somewhere others can read, assume it's been copied.
Can I catch this in CI?
Yes. Grep the build output for secret key prefixes, or run Launch Audit's scanner with --fail-on=high.