Your Supabase service_role Key Is Probably in Your Front End
There are two Supabase keys, and confusing them is the most expensive mistake in AI-assisted development.
The publishable/anon key is meant to be public. It ships in your browser bundle and is safe because row-level security limits what it can reach. The service-role key bypasses row-level security completely. It is a root credential for your database.
AI coding tools mix them up. When a generated query fails because a policy blocks it, the fastest way to make the error disappear is to use the key that ignores policies — and that fix sometimes lands in client-side code. Scans of AI-built Supabase apps have found admin keys sitting in front-end JavaScript in a meaningful share of cases.
How it gets there
- Bundler-prefixed env vars. A secret given a client prefix (
VITE_,NEXT_PUBLIC_,PUBLIC_) is inlined into the shipped JavaScript. The name is the whole bug. - A second client module. A file that creates an admin client and gets imported — even transitively, even for a type — by a component. One import pulls it into the browser graph.
- Committed env files.
.env,.env.local, or a config snapshot committed once and still in git history after being deleted. - Pasted into a chat or an issue. Then copied into code by the next tool that read it.
Finding it
Start with what you actually ship:
# search the built client bundle, not just src/
rg -o 'eyJ[A-Za-z0-9_-]{20,}|sb_secret_[A-Za-z0-9]+' dist/ | sort -uThen history:
git log -p --all -S 'service_role' -- . | head -100
git log --all --name-only --diff-filter=A | rg '\.env'Then the import graph. If any module that constructs an admin client is reachable from a route or a component, it ships — a typeof window guard does not remove it from the bundle.
What a leak actually gives an attacker
Full read and write on every table, regardless of policies. Storage access regardless of bucket rules. Ability to delete rows, forge records, and read every user's data. There is no partial version of this.
The correct response order
- Rotate first. In the Supabase dashboard, roll the service-role key. Everything else is theatre until this is done.
- Update server environments — hosting provider, background workers, CI — with the new value.
- Remove the reference from client code and move the privileged call into a server function that verifies the caller.
- Purge history if the key was committed, and add the file pattern to
.gitignore. - Verify. Rebuild and grep the fresh bundle. A fix you did not verify is a belief.
- Check the database for damage — unexpected rows, deleted records, new auth users, storage objects.
Preventing the next one
Keep exactly one module that can construct a privileged client, keep it server-only by filename convention, and import it dynamically inside the handler that needs it. Read secrets from process.env inside the function body, never at module scope. And give nothing sensitive a client-side prefix, ever.
Sentrail scans your repository and its history for exposed credentials, tells you which ones reached a shipped bundle, and tracks rotation as evidence rather than a checkbox.
