Skip to content
Security

Your service-role key is in your JavaScript bundle

A Supabase service-role key in your JavaScript bundle is a database superuser in public. Find it in ninety seconds, then rotate in the right order.

10 min readKato Chojiro

The Supabase service-role key bypasses row-level security completely. Every table, every row, read and write, no policies evaluated. It is not an API key in the ordinary sense; it is a database superuser handed out as a bearer token. It belongs in exactly one place: server-side code that you control.

We find it in shipped frontend bundles regularly enough that it has its own line on our audit checklist. The same goes for Stripe secret keys, OpenAI keys, Resend keys, and AWS access keys. The mechanism is always one of about five, and they are all easy to reproduce accidentally.

How it gets there

1. The anon key was not working, so someone swapped it

This is the big one. A query returns an empty array because RLS is denying it. The developer, or the agent, tries the other key from the dashboard. The query starts returning data. The bug is closed. Nobody notices that the fix was to disable authorization for the entire application.

2. The build tool inlined it

Vite inlines anything prefixed VITE_. Create React App inlines REACT_APP_. Next.js inlines NEXT_PUBLIC_. These are not runtime lookups; the string is substituted into the bundle at build time. When process.env.SUPABASE_SERVICE_ROLE_KEY comes back undefined in the browser, the obvious-looking fix is to rename the variable to NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. It works immediately, and it publishes the key.

3. A server module got imported by a client component

A file called lib/admin.ts reads the key at module scope. It is server-only by intent, but a client component imports one helper from it - a type guard, a formatter - and the whole module joins the client graph. The bundler happily inlines the environment variable it can statically resolve.

4. A hardcoded fallback

ts
// Written during debugging. Never removed.
const key =
  process.env.SUPABASE_SERVICE_ROLE_KEY ??
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";

5. It is in git, and git is forever

A .env file committed in week one, deleted in week three. The deletion commit does not remove it from history. If the repository is public, or ever becomes public, or is cloned by a contractor, the key is out.

Find it in ninety seconds

Build the app the way you deploy it, then search the output. Supabase keys are JWTs, so they match a very specific shape:

bash
npm run build

# JWT-shaped strings in the build output
grep -roE "eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" \
  dist/ build/ .next/static/ 2>/dev/null | sort -u

# Other common secret shapes
grep -rniE "service_role|sk_live_|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xoxb-|BEGIN (RSA )?PRIVATE KEY" \
  dist/ build/ .next/static/ 2>/dev/null | cut -c1-160

Then decode any JWT you find. The payload tells you immediately which key it is:

bash
echo "eyJhbGciOi..." | cut -d. -f2 | base64 -d 2>/dev/null; echo
# {"iss":"supabase","ref":"abcdefgh","role":"anon","iat":1731369600,"exp":2046902400}
#                                     ^^^^^^^^^^^^
# "role":"anon"         -> expected, public by design
# "role":"service_role" -> stop reading and start rotating

For git history, use a scanner rather than grep - history rewriting and packfiles make manual searching unreliable:

bash
# Either of these will do
gitleaks detect --source . --redact
trufflehog git file://. --only-verified

If you find one, work in this order

Order matters, because every hour the key stays valid is an hour of exposure, and the temptation is always to fix the code first so nothing breaks. Resist it.

  1. Assume disclosure. A string in a public bundle has been indexed by crawlers, cached by proxies, and archived. Treat it as known, not as possibly known.
  2. Rotate the key immediately. In Supabase this means rotating the JWT secret, which invalidates the anon key and existing user sessions too. Plan for users being signed out; that is a far better outcome than the alternative.
  3. Fix the code that depended on it, and redeploy with the new configuration.
  4. Audit for damage. Check auth.users for unexpected signups, row counts on your largest tables, storage object listings, and anything money-shaped: refunds, plan changes, credit balances.
  5. Enable and test RLS on every table. The exposed key was masking the fact that there was no second layer of defence.
  6. Purge the secret from git history if it was ever committed, and enable push protection so the next one is blocked before it lands.

What actually needs the service role

Far less than people assume. In a typical AI-built app the honest list is: administrative dashboards, webhook handlers, scheduled jobs, bulk imports, and signed-URL generation. Every one of those is server work. None of them needs to run in a browser.

Moving the call server-side is only half the job. A route handler holding the service key with no authorization check of its own is the same vulnerability behind a slightly longer URL.

ts
// app/api/admin/users/route.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

// Note the variable names: no NEXT_PUBLIC_ prefix anywhere.
const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } }
);

export async function GET(req: Request) {
  // 1. Who is asking? Verify the caller's own session token.
  const caller = await getSessionUser(req);
  if (!caller) return new Response("Unauthorized", { status: 401 });

  // 2. Are they allowed? Check a role source the user cannot write to.
  if (!(await isAdmin(caller.id))) {
    return new Response("Forbidden", { status: 403 });
  }

  // 3. Only now use the privileged client, and select only what is needed.
  const { data, error } = await admin
    .from("users")
    .select("id, email, created_at")
    .order("created_at", { ascending: false })
    .limit(100);

  if (error) return new Response("Query failed", { status: 500 });
  return Response.json(data);
}

The import "server-only" line on the first line is the cheapest guardrail in the whole file. If any client component ever imports this module, the build fails with a clear error instead of silently shipping the key.

Stopping it from coming back

  • Naming discipline: nothing secret ever gets a NEXT_PUBLIC_, VITE_, or REACT_APP_ prefix. Make it a review rule with no exceptions, because the exception is always the one that leaks.
  • Put import "server-only" at the top of every module that reads a secret, and import "client-only" where it helps mark the boundary from the other side.
  • Add a build-time grep as a CI job so a leaked key fails the pipeline rather than reaching the CDN.
  • Turn on secret scanning and push protection in your git host, plus a pre-commit hook so it is caught before it is even pushed.
  • Separate development and production projects. A leaked development key should be an annoyance, not an incident.
  • Scope third-party keys down. Stripe restricted keys, per-service AWS IAM roles, and read-only tokens all shrink the blast radius when something does escape.
yaml
# .github/workflows/secret-scan.yml
name: secret-scan
on: [push, pull_request]

jobs:
  bundle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run build
      - name: Fail if a service_role JWT reached the bundle
        run: |
          FOUND=$(grep -roE "eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" \
            .next/static dist build 2>/dev/null | sort -u || true)
          for token in $FOUND; do
            payload=$(echo "$token" | rev | cut -d/ -f1 | rev | cut -d. -f2)
            if echo "$payload" | base64 -d 2>/dev/null | grep -q service_role; then
              echo "::error::service_role key found in client bundle"
              exit 1
            fi
          done

None of these controls is clever. They are all boring, and boring is the point: the failure mode here is not a sophisticated attack, it is a rename that made an error message go away at 1am. Guardrails that fail the build are the only kind that survive contact with a deadline. If you want the same search run across the rest of your codebase, it is the first hour of an AI app security audit.

KC

Written by Kato Chojiro

Takes AI-built MVPs to production: security, data, and the infrastructure underneath.

Connect on LinkedIn
Book a Free Call

Want a second pair of eyes on your app?

The Production Check is free, and the written report is yours to keep.

Book a Free Call