Zpět na blog
14. srpna 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

Jaké typy zranitelností Penetrify detekuje?

Penetrify detekuje všechny kategorie zranitelností OWASP Top 10, včetně SQL injection, XSS, CSRF, IDOR, broken authentication, bezpečnostních misconfigurations a expozice citlivých dat. Testuje také bezpečnost API, správu relací a běžné misconfiguration v Supabase, Firebase a Bubble.

Jak dlouho trvá AI penetrační test?

Rychlý sken je dokončen za 15–30 minut. Standardní sken trvá 1–2 hodiny s širším pokrytím. Hloubkový sken může pro komplexní aplikace trvat několik hodin.

Co obsahuje zpráva Penetrify?

Každá zpráva obsahuje executive summary, celkové bezpečnostní skóre, nálezy klasifikované dle závažnosti (Kritická, Vysoká, Střední, Nízká), kroky pro reprodukci a konkrétní doporučení pro nápravu napsaná pro vývojáře – ne pro compliance manažery.

Related articles

Supabase RLS Chybná konfigurace: Jak chybějící politika odhalila profily všech uživatelů
Samostatný zakladatel uvedl na trh SaaS aplikaci postavenou na Next.js a Supabase pro více než 200 uživatelů. Osm minut po zahájení skenování Penetrify jsme objevili kritickou chybnou konfiguraci RLS, která jakémukoli ověřenému uživateli umožňovala číst profilová data všech ostatních uživatelů. Zde je přesně popsáno, co bylo špatně, proč k tomu dochází a jak to bylo opraveno do dvou hodin.
Tajný klíč Stripe ve frontendovém balíčku: 4 měsíce tiché expozice
Dvoučlenný tým vytvořil tržiště na platformě Bubble.io, které zpracovávalo platby v hodnotě přes 40 000 USD. Jejich tajný klíč API služby Stripe byl čtyři měsíce uložen v balíčku JavaScriptu na straně klienta – což dávalo komukoli, kdo se podíval, plný přístup pro čtení i zápis k celé jejich platební infrastruktuře. Zde je, jak se to stalo, co bylo ohroženo a co s tím udělali.
Pen Tester: Kompletní průvodce rolí v roce 2026
Ve světě, který je stále více závislý na digitální infrastruktuře, jsou odborníci, kteří za to berou peníze a legálně ji rozbíjejí, důležitější než kdy dřív. Ale co vlastně takový pen tester celý den dělá? Pro mnohé je tato role zahalena tajemstvím, často zaměňována s jinými pozicemi v oblasti kybernetické bezpečnosti, a nyní čelí naléhavé otázce…

Explore more