RowShield
Guides

Detect a service_role key in your client bundle

The service_role key carries BYPASSRLS. Every policy you have written is decorative to anyone holding it. Once it is in a bundle served to browsers it is public, and no amount of RLS work matters until it is rotated.

This happens most often when an AI tool needs an admin operation, finds the service key in the environment, and wires it into client code because that is where the call was being written.

Rules that check this

How the check works

RowShield fetches your deployed page, follows the same-origin script tags and module preloads, and scans the bundles for Supabase credential formats: legacy JWTs, `sb_secret_*`, `sb_publishable_*` and management tokens `sbp_*`.

A JWT is classified by decoding its payload and reading the `role` claim — unverified, deliberately, because the question is what the key claims to be, not whether the signature is good.

The key is never stored

A finding records a fingerprint — the first six characters and a length — never the key. Recording a live RLS-bypassing credential in our database, then rendering it in a dashboard, would make the leak worse.

What to do when you find one

Rotate it first, in the Supabase dashboard under Settings → API. Everything using the old key stops working, which is the point: you need that list.

Then move whatever needed it behind a server route, a Next.js Route Handler, or an edge function. If the operation genuinely needs to bypass RLS, it does not belong in code you ship to browsers.

Where service keys actually end up

The patterns repeat across projects. An environment variable gets prefixed for the browser — NEXT_PUBLIC_ in Next.js, VITE_ in Vite — during a refactor that "made it work", and the key rides every subsequent bundle. An agent asked to seed data finds the admin credential and wires it into a client-side call because that file was already open. A preview deployment inherits server variables through misconfigured framework conventions and prints them into client chunks.

None of these look like leaks from inside the editor; all of them are visible from outside with nothing but view-source. That asymmetry is why the check runs against your deployed site rather than your repository: the repository is not what attackers fetch.

Rotation discipline matters as much as detection. Treat any exposure finding as compromised immediately — rotate first, then fix the code path, then re-run the probe to confirm the new key never appears. RowShield stores only a fingerprint of exposed keys, so re-scans verify absence without ever retaining the secret itself.

A word on what rotation actually costs: every legitimate caller of the old key breaks at once, and that list is the point. Teams are usually surprised by its length — a forgotten analytics script, an internal dashboard, a teammate’s local .env. The breakage is bounded, loud, and fixable in minutes; the alternative is a bypass-everything credential circulating publicly with no expiry date. Even teams with no incident to point at benefit from the standing check, because bundle contents drift with every build and nobody diffs minified output by hand.

Frequently asked

Do you need access to my repository?
No. The check reads your deployed site the way any visitor can. No GitHub access and no signup.
What if my config loads at runtime?
Then the bundle scan finds nothing and the report says so. Paste your project URL and anon key directly and the table checks still run.
Is the scan safe to run against production?
It only issues GET, HEAD and OPTIONS. There is no code path that can write, and loopback and private address ranges are refused before the socket opens.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase service_role key leaksupabase service_role key in client bundlebolt.new supabase service_role exposeddetect service_role key exposure in Next.js