We have written about a missing Row Level Security policy exposing every user's profile. This is the sibling failure with the opposite root cause: the policies are written correctly, and a key that is documented to bypass them ends up in the client bundle.
It is worse than a missing policy, because there is nothing to fix in the database. The database is behaving exactly as designed.
What service_role Actually Does
Supabase issues two keys for a project. The anon key is public by design: it identifies your project, it is meant to sit in the browser, and every request made with it is subject to Row Level Security. The service_role key is the opposite — it is documented as bypassing RLS entirely, for server-side work like migrations, admin tooling and background jobs.
That is not a bug or a subtlety. It is the stated purpose. A request carrying service_role reads and writes any row in any table regardless of what your policies say, because policy evaluation is skipped. The key is a database superuser in an HTTP header.
So the security model is simple and unforgiving: RLS protects you from the anon key, and nothing protects you from service_role. If it leaks, your access control is not weakened, it is absent.
How It Ends Up in the Browser
Nobody pastes a superuser key into a React component on purpose. Three paths do it accidentally, and all three look reasonable while you are writing them.
The environment variable prefix. In Next.js, any variable named NEXT_PUBLIC_* is inlined into the client bundle at build time. A developer hits a permissions error in a server action, swaps the anon key for the service key to unblock themselves, and keeps the existing variable name — which happens to carry the prefix. The error goes away. The key is now in JavaScript served to every visitor. Vite's VITE_* and Create React App's REACT_APP_* behave identically.
The shared client module. A single lib/supabase.ts that creates the client is imported by both server and client code. Someone adds the service key to it so that an admin page works, and the bundler follows the import graph into the browser build. The module was fine yesterday; the import that pulled it client-side was added by someone else.
The build-time fetch. Static generation runs at build time with full server privileges, and a developer reasonably uses the service key there. Then the same helper is reused in a component that also renders on the client, and the key travels with it.
In every case the application works. That is the whole problem: there is no error, no warning, and no failing test. The only symptom is a string in a JavaScript file that nobody reads.
What the Exposure Means in Practice
With the service key, an attacker does not need to find a flaw in your application. They can talk to your database directly through the Supabase REST endpoint, which is public by design and authenticated by the key alone.
That means reading every row of every table — users, subscriptions, messages, uploaded documents, whatever you store. It means writing too: changing a subscription tier, promoting an account to admin if you keep roles in a table, editing balances. It means deleting, which turns a data breach into an availability incident. And because these requests never touch your application code, none of your application logging sees them.
Storage is included. If your buckets rely on RLS-style policies, the service key ignores those as well, so private uploads are readable and replaceable.
The practical severity is total compromise of the data layer with no exploitation skill required. Finding the key is the entire attack.
How to Check Your Own Application in Two Minutes
Build your app and search the output. Both keys are JWTs, so they are easy to spot and easy to tell apart:
# Next.js
npm run build && grep -ro "eyJ[A-Za-z0-9_-]\{20,\}" .next/static | sort -u
# Vite / CRA
npm run build && grep -ro "eyJ[A-Za-z0-9_-]\{20,\}" dist build 2>/dev/null | sort -u
Then decode each hit — the payload is base64 and contains a role claim. "role":"anon" is expected. "role":"service_role" is an incident:
echo '<middle-segment-of-the-jwt>' | base64 -d
Do the same against production rather than only locally, because the variable that leaks is often only set in the deployment environment. Fetch your main bundle and grep it. And check your git history: a key that was committed once and later removed is still in the history and still valid until rotated.
The Fix, and the Order It Has to Happen In
Rotate first. In the Supabase dashboard, generate new keys — the exposed one is compromised the moment it is public, and there is no way to know who fetched a bundle. Rotating before you fix the code means the leaked value is dead even if the deploy takes a day.
Then remove the cause. Server-only secrets must not carry a public prefix: use an unprefixed name, keep the service client in code that only ever runs on the server (a route handler, a server action with no client import path, an edge function), and split your client module so the browser build cannot reach the privileged one.
Then add the check that stops it recurring. Grepping the build output for a service_role JWT is a five-line CI step, and it catches the class permanently rather than this instance. If you take one thing from this piece, take that: the fix is a pipeline check, not a code review habit.
Finally, assume exposure. Review your Supabase logs for direct REST access patterns you cannot attribute to your own servers, and treat the window between deployment and rotation as a period where your data may have been read.
Why This Pattern Persists
Because the framework makes the mistake convenient and the platform makes it silent. A prefix that means "publish this to the browser" sits one character away from a variable name that means "keep this secret", and the tooling does exactly what it was told without asking whether you meant it. Meanwhile Supabase cannot warn you, because a service key in a browser is indistinguishable from a service key in a legitimate server that happens to be behind a CDN.
It also survives review, because the diff that introduces it looks like a fix. The pull request says "resolve permission error on admin page", the tests pass, the feature works. Nothing in that changeset announces that the security boundary moved.
Which is why this belongs in automated testing rather than in discipline. In the applications we scan, secrets reaching the client bundle remain one of the most frequent critical findings, and they are among the cheapest to detect — a scan reads the same bundle a visitor does. If you want the wider picture of how these findings cluster, our security report breaks the categories down.
Related Failures Worth Checking at the Same Time
If the service key leaked, check three neighbours while you are in there. Are your RLS policies actually enabled on every table, or only on the ones you remembered? Does any table have policies written but RLS switched off, which reads as protected and is not? And is your storage bucket policy doing what you think, particularly for uploads whose filenames a user controls?
Each of those is independently sufficient for a breach, and they tend to travel together — because the same afternoon that produced the leaked key was the afternoon somebody was fighting the permissions model.
