All posts
VibeZero Team8 min read

Service role key exposure, explained

The Supabase key that ignores every RLS policy you wrote, why AI builders keep putting it in the browser, and the exact order to fix it in when it leaks.

  • security
  • vibe coding
  • engineering

Most leaked credentials give an attacker a capability. This one gives them your database.

The Supabase service_role key, reissued in the 2025 format as sb_secret_..., maps to a Postgres role carrying the BYPASSRLS attribute. Supabase’s API keys documentation says it plainly: the secret key has full access to your project’s data and skips “any and all Row Level Security policies you attach.” The Row Level Security guide repeats the warning from the other direction, that these keys “should never be used in the browser or exposed to customers.”

So every policy discussed in RLS misconfiguration explained, every auth.uid() = user_id you wrote and tested, is not weakened by this key. It is not evaluated. The database does not consult your policies for this role at all.

Two keys that look identical and could not differ more

This is a human-factors problem before it is a security problem, and it explains why the mistake is so common in generated code.

Under the legacy format both keys are JWTs. Both start with eyJ. Both are roughly the same length. Both live in the same page of the Supabase dashboard, one above the other. The only difference is a claim inside the payload, which you cannot see without base64-decoding the middle segment:

{ "iss": "supabase", "ref": "abcdefghijklmnopqrst", "role": "anon" }
{ "iss": "supabase", "ref": "abcdefghijklmnopqrst", "role": "service_role" }

One of those is meant to ship inside your JavaScript bundle. The other reads and writes every table exposed over the Data API with your policies switched off. They differ by one word, in a field nobody looks at, inside a string nobody reads.

The 2025 format fixes exactly this by writing the tier into the prefix: sb_publishable_... is public, sb_secret_... is not, and you can tell at a glance. Supabase says the legacy keys “will be deprecated by the end of 2026”, so if you are still on JWT keys, migrating buys you a permanent reduction in how plausible this mistake is. That is a rare thing to be able to buy.

How it reaches the browser

Four routes, and the first two account for nearly everything we see.

A client-exposed environment prefix. VITE_SUPABASE_SERVICE_ROLE_KEY in a Vite app or NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY in Next.js. These prefixes are not a naming convention, they are an instruction to the bundler to inline the value into the JavaScript it ships. The variable name reads like a precaution. It is the opposite: it is the one prefix that guarantees publication. This is how it happens on Bolt, whose own database documentation has recommended the VITE_ prefix for Supabase keys.

RLS blocked a query, and the key unblocked it. This is the interesting one, because nobody involved was being careless. The feature is “an admin can see all orders”. The policy correctly denies it. The app breaks. The fastest fix that works, and the one a coding agent will reach for when you paste the error, is a client initialized with the service key, because that client returns rows and the error goes away. The bug is introduced by a fix that worked, which is why it turns up in projects whose owners did read the guidance: the Lovable apps with this problem usually have policies written, and then bypassed.

A server-side surface that is not as server-side as it looks. An Edge Function is a fine place for this key. A Next.js Server Component that passes data to a client component is fine for the data and not for the key. A value that is read on the server and then placed in a prop, a window.__DATA__ blob, or a JSON response has been published.

Git history. .env committed once, four months ago, removed in a later commit. The working tree is clean and the key is still in the repository, in every clone and every fork. That mechanism, and the reason deleting the commit does not help, is covered in API key exposure explained.

What the key does in an attacker’s hands

Worth spelling out, because “full database access” is abstract until you see the requests. All of these are ordinary API calls with no exploit involved.

Read every row of every table exposed over the API, ignoring policies:

curl -s "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: <service-role-key>" \
  -H "Authorization: Bearer <service-role-key>"

Write and delete anything, including rows the UI never exposes:

curl -X PATCH "https://<project-ref>.supabase.co/rest/v1/subscriptions?id=eq.42" \
  -H "apikey: <service-role-key>" -H "Authorization: Bearer <service-role-key>" \
  -H "Content-Type: application/json" \
  -d '{"plan":"enterprise","status":"active"}'

And, in the client libraries, reach the admin surface that only this key opens:

// Requires the service key. Lists every user in the project, with emails.
const { data } = await admin.auth.admin.listUsers();

That last one matters for the disclosure conversation you may end up having. The auth admin API turns an exposed key into a full user list with email addresses, which is personal data, which is a notification obligation in most jurisdictions rather than a technical inconvenience.

The order to fix it in

Rotation comes first, and it is the only step that closes anything. Everything else is cleanup that can be done at a normal pace once the key is dead.

  1. Rotate the key in the Supabase dashboard. Settings, API Keys, create a new secret key, replace it in your server components, then delete the old one. Supabase warns that deleting a secret key is irreversible, so create and roll out the replacement before removing the old one, and do not skip the removal because the rollout felt finished.
  2. Read the logs for the exposure window. Supabase’s logs will show API requests. You are looking for reads you cannot account for, and for anything touching auth.users. Rotation ends the future; only the logs say anything about the past.
  3. Move the call, not just the string. A key that has to be secret needs to be used somewhere the client cannot reach: an Edge Function, a route handler, a backend service. Putting the same key behind a differently named variable in the same frontend is the same bug with better branding.
  4. Then clean the history, if it was committed. With the key revoked, the old blob is a dead string, and git filter-repo becomes tidiness rather than remediation.

Designing so you never need it in the client

The reason this key ends up in a browser is almost always that a legitimate operation needed more authority than the current user has. There are two correct ways to grant that, and both keep the authority on the server.

A database function that carries its own check. security definer runs the function as its owner, which lets it read past RLS, so the check has to be written inside the function and the search path has to be pinned. set search_path = '' is the setting Supabase’s own linter asks for, and it means every name in the body has to be schema-qualified, which is why public. and auth. appear on everything below:

create or replace function public.admin_order_totals()
returns table (order_id bigint, total numeric)
language plpgsql
security definer
set search_path = ''
as $$
begin
  -- The authorization the service key skipped, written down explicitly.
  if not exists (
    select 1 from public.memberships
    where user_id = auth.uid() and role = 'admin'
  ) then
    raise exception 'not authorized';
  end if;

  return query select o.id, o.total from public.orders o;
end;
$$;

revoke execute on function public.admin_order_totals() from public;
grant execute on function public.admin_order_totals() to authenticated;

The caller uses the ordinary publishable key. The elevated authority exists only inside one function whose first act is to check who is asking.

An Edge Function or a backend route. When the work is not expressible in SQL, put the secret key in the function’s environment, authenticate the caller by their own token, and let the function do the privileged part. The rule is the same as the SQL version: the key is available to code that checks, never to code that a browser runs.

Both patterns pair with column grants, which is the control that keeps a legitimate update from writing role or plan. That mechanism is mass assignment, and on a Supabase project it is enforced with grant update (col, col) rather than in application code.

What we detect, and the deliberate asymmetry

Our free scanner reads the JavaScript a site actually serves and grades the two Supabase key tiers very differently, which took more code than a regex would have.

A service_role JWT or an sb_secret_ key in the bundle is reported as supabase-service-role-key, severity critical, with the plainest impact text we write anywhere: anyone who views your page source can read, change and delete every row in your database. A publishable or anon key is reported as supabase-anon-key, severity info, and the text says the opposite, that this is where the key belongs and it is worth knowing only because RLS is then the only control standing.

Telling them apart requires base64-decoding the JWT payload and reading the role claim, because the two are otherwise indistinguishable to a pattern matcher. Getting that backwards in either direction is a product failure: report the anon key as critical and you cry wolf on every correctly built Supabase app until people stop reading the output, miss the service key and you have skipped the single worst thing the scan can find. The whole project is reported as one finding rather than three, with the project ref folded into the evidence, for the same reason: a URL in one bundle and a key in another are one sentence about one app.

You can run that check against your own deployed site with the Supabase key checker, and for the repository side of it, Gitleaks and TruffleHog over the full history rather than the working tree, since the bundle only shows you today.

Why this one is graded above everything else

Most findings describe a way in. This one describes the absence of a door. There is no exploit to write, no race to win, no user to phish. The credential is published, it answers to anyone, and it was designed to ignore precisely the controls you built to keep people apart.

That is why it blocks a release outright in our decision model rather than joining a backlog with a severity label, and why the fix has a strict order with rotation first. Every minute the key is live is a minute of exposure you cannot retroactively close, and the checklist item that catches it takes about thirty seconds: open your deployed site, view source, search for service_role and for sb_secret_.

ShareXLinkedIn