The service_role key is the master key to your database. It ignores your row level security rules completely. This page is the calm runbook: kill the key correctly, find where it leaked, then find out whether anyone actually read your data.
Jump to the rotation steps → Check your live site (free)Supabase gives every project two kinds of keys. The publishable key (the old "anon" key) is meant to be public. It goes in your frontend, and your row level security policies (RLS: per-row rules that decide who can see what) are what keep users inside their own data.
The secret key is the opposite. Supabase's own docs say it plainly: it has full access to your project's data, bypassing row level security. Every policy you wrote stops applying. Whoever holds it can read every row of every table, write, and delete, straight through your project's API, from anywhere on the internet. No login needed. The key is the login.
So "my service_role key was in the frontend" is not a config nit. It means anyone who opened your site's JavaScript had a master key to your database. The fix has two halves: rotate the key (part 1) and find out what happened while it was exposed (part 2). Do them in that order. Rotation stops the bleeding; forensics can wait twenty minutes.
Supabase now has two key systems, and the steps differ. Check which one you are on first: in your project dashboard, open Settings → API Keys. New-style secret keys start with sb_secret_. Legacy keys are long JWTs starting with eyJ.
SUPABASE_SERVICE_ROLE_KEY (or whatever you named it) everywhere it lives: your server's .env, your host's environment variables (Vercel, Netlify, Fly, wherever you deploy), and your CI secrets. Redeploy and confirm the app still works.One nice property of the new system: rotating a secret key does not touch your users. Their sessions are unaffected, because user logins are verified separately from API keys.
The legacy anon and service_role keys are both signed by one shared JWT secret, so they cannot be rotated one at a time. Per Supabase's rotation guide, regenerating the JWT secret invalidates both keys immediately, and severs connections that were using them. That means:
NEXT_PUBLIC_SUPABASE_ANON_KEY or similar). Miss the anon key and your whole app breaks, not just the admin bits.Better still: this is the moment to migrate to the new sb_secret_ keys, so the next rotation is a non-event. Supabase's API keys guide covers the migration.
A rotated key fixes nothing if the code that leaked it is still deployed. Three places to check, in order of likelihood.
.js files, and search them for sb_secret_, for service_role, and for eyJ. Or from a terminal:
curl -s https://your-app.com/path/to/bundle.js | grep -E "sb_secret_|service_role|eyJ"
The usual cause is an environment variable named with a public prefix. Anything starting NEXT_PUBLIC_ or VITE_ is shipped to the browser by design, so NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is a leak with extra steps. The secret key belongs in a variable without the public prefix, used only in server code.git log -S "sb_secret_" --all --oneline
git log -S "service_role" --all --oneline
git log --all --oneline -- .env .env.local
If a .env file appears anywhere in history, every key that was ever in it needs rotating, not just this one. Our general leaked-key runbook covers scrubbing history and why rotation always comes first..env or .git directory to anyone who asks for it. We wrote up what an exposed .env file means separately.Then verify the fix. After you redeploy, check that the new bundle is clean. Our free scanner looks at your live site passively, with no signup, and checks for exposed config files and other leaks reachable from outside. If you built the app with an AI tool, the Lovable checklist and the Bolt key-leak page cover the mistakes those tools tend to repeat.
The key is dead. Now the harder question. Supabase keeps logs, and they can answer it, within limits we will be honest about.
In your project dashboard, open the Logs section. The API logs record every request to your project's REST API as it passes through the edge: the path, the HTTP method, the status code, the caller's IP address, and the user agent. There are separate logs for Postgres, Auth, and Storage, plus a Logs Explorer for querying across them. Start with the API logs, filtered to the window the key was exposed.
Three patterns stand out. Bulk reads: long runs of GET requests against one table, paging through it chunk by chunk. Table walking: requests moving through table after table, often in a tidy order no human browses in. Wrong callers: your service key should only ever be used from your own servers, so requests hitting admin-only tables from IPs that are not your infrastructure are the smoking gun.
In the SQL editor, the pg_stat_user_tables view keeps per-table counters, including sequential scans and rows read. Try: select relname, seq_scan, seq_tup_read from pg_stat_user_tables order by seq_tup_read desc; A table your app only ever touches row-by-row showing millions of rows read is corroboration. But know the limits: these counters are cumulative since the stats were last reset, with no timestamps and no record of who did the reading. They can support a suspicion, not prove one.
Log retention follows your plan: at the time of writing, Supabase's pricing page lists about one day of API and database logs on the Free plan and seven days on Pro. If the key sat exposed for weeks, most of the evidence is already gone. So absence of evidence is not evidence of absence. The sober rule: if the key was public and the logs cannot rule out access, treat the data as possibly read, and act accordingly.
If you find scraping, or cannot rule it out: first write down the timeline while it is fresh: when the key was exposed, when you rotated, what the logs show. Then take stock of what was actually in those tables. Emails and names are one thing; passwords are another (if you used Supabase Auth, password hashes are not sitting in your own tables); health or payment data is another level again. If personal data was likely read, notification duties may apply: several jurisdictions set deadlines measured in days for telling regulators or affected users. We are not lawyers and this is not legal advice, so if real user data was exposed, talk to one early. One thing we can say from experience on the disclosure side: users forgive an early, plain-English "here is what happened and what we did" far more readily than they forgive finding out some other way.
A leaked service key usually has company. The same habits that put it in the bundle tend to leave an AI-provider key in a commit or an unprotected admin route in production.
Our free, open-source Claude Code plugin audits your codebase against a 48-check catalog: leaked keys, exposed configs, open databases, broken access control, and the rest of what gets apps breached, reported in plain English. Install it with:
/plugin marketplace add Raffa-jarrl/Lictor-AI
/plugin install lictor-security-suite@lictor-ai
And because "trust us" is not evidence: we publish a reproducible benchmark. In a blind run on a 30-case stratified sample of OWASP Benchmark 1.2, the audit scored an F1 of 93.8%. That is a sample, not the full benchmark, and we say so right in the results. If you work in Claude Code day to day, here is how the whole suite fits into it.
Everything above happened because the leak was found late. You reconstructed events from one day of logs because nobody was watching when it mattered.
That is the part you can fix permanently. Lictor's continuous monitoring re-checks your live site around the clock and emails you an alert when something new is exposed: a config file, a key in a fresh bundle, an open surface that was not there yesterday. That shrinks the gap between "leaked" and "known" from weeks to a single alert, and a short gap is what turns the forensics above from archaeology inside a one-day log window into a quick check. It starts at $49/mo, and it is run by a security engineer with 20 years in the field who does real responsible-disclosure work.
Everything. It bypasses row level security completely, so anyone holding it can read and write every row in every table through your project's API: export your user table, change records, delete data. Your RLS policies do not apply to it. That is why it lives only on servers you control, never in a frontend bundle, a mobile app, or a repo. The publishable (anon) key is designed to be public. The secret key never is.
Not automatically. New-style sb_secret_ keys are independent: creating a new one does not disable the leaked one. Create the replacement, switch your backend to it, then delete the leaked key. The deletion is the revocation, and it is irreversible. On legacy JWT-based keys, the fallback is regenerating the JWT secret, which invalidates the anon and service_role keys together, so update both and expect users to be signed out.
Check the API logs in your dashboard's Logs section for the exposure window: bulk paged reads on one table, requests walking table after table, and caller IPs that are not your servers. In the SQL editor, pg_stat_user_tables can corroborate unusually large reads, though it has no timestamps and no caller identity. And mind the retention limit: about one day of logs on the Free plan. If the key was public and the logs cannot rule out access, assume the data may have been read.