Zurück zum Blog
14. August 2026

The Supabase service_role Key in the Client Bundle: RLS Bypassed Entirely

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

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.

Frequently Asked Questions

Welche Arten von Sicherheitslücken erkennt Penetrify?

Penetrify erkennt alle OWASP-Top-10-Schwachstellenkategorien, darunter SQL-Injection, XSS, CSRF, IDOR, fehlerhafte Authentifizierung, Sicherheitsfehlkonfigurationen und die Offenlegung sensibler Daten. Es testet auch die API-Sicherheit, das Session-Management und häufige Fehlkonfigurationen in Supabase, Firebase und Bubble.

Wie lange dauert ein KI-Penetrationstest?

Ein Quick-Scan ist in 15–30 Minuten abgeschlossen. Ein Standard-Scan läuft 1–2 Stunden mit breiterer Abdeckung. Ein Deep-Scan kann für komplexe Anwendungen mehrere Stunden dauern.

Was enthält ein Penetrify-Bericht?

Jeder Bericht enthält eine Executive Summary, einen Gesamtsicherheitsscore, nach Schweregrad klassifizierte Befunde (Kritisch, Hoch, Mittel, Niedrig), schrittweise Reproduktionsschritte und konkrete Abhilfemaßnahmen – geschrieben für Entwickler, nicht für Compliance-Beauftragte.

Related articles

Supabase RLS-Fehlkonfiguration: Wie eine fehlende Richtlinie jedes Nutzerprofil offenlegte
Ein Einzelgründer hat ein Next.js- und Supabase-basiertes SaaS an über 200 Nutzer ausgeliefert. Acht Minuten nach Beginn eines Penetrify-Scans entdeckten wir eine kritische RLS-Fehlkonfiguration, die es jedem authentifizierten Nutzer ermöglichte, die Profildaten aller anderen Nutzer zu lesen. Hier erfahren Sie, was genau fehlerhaft war, warum es dazu kam und wie es in weniger als zwei Stunden behoben wurde.
Der Stripe Secret Key im Frontend Bundle: 4 Monate stiller Exposition
Ein zweiköpfiges Team entwickelte einen Bubble.io-Marktplatz, der Zahlungen im Wert von über 40.000 US-Dollar abwickelte. Ihr geheimer Stripe API-Schlüssel befand sich vier Monate lang im clientseitigen JavaScript-Bundle – was jedem, der danach suchte, vollen Lese-/Schreibzugriff auf ihre gesamte Zahlungsinfrastruktur ermöglichte. So kam es dazu, was auf dem Spiel stand und was sie dagegen unternahmen.
Pen Tester im Jahr 2026: Der ultimative Leitfaden zur Rolle im Penetration Testing
In einer Welt, die zunehmend von digitaler Infrastruktur bestimmt wird, sind die Fachleute, die dafür bezahlt werden, diese legal zu kompromittieren, wichtiger denn je. Aber was genau macht ein Pen Tester eigentlich den ganzen Tag? Für viele ist diese Rolle von Geheimnissen umgeben, wird oft mit anderen Cybersecurity-Berufsbezeichnungen verwechselt und steht nun vor dringenden Fragen...

Explore more