THE SUPABASE SERVICE-ROLE KEY IN YOUR FRONTEND BUNDLE

There is a key called the anon key, which is fine to ship to the browser. There is another key called the service-role key, which is the database admin key. AI generators keep shipping the second one. Here's why, and here is the live URL where you can watch it happen.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate for scanner calibration. The client engagement that originally surfaced this pattern is anonymized; the gapbench scenario is the reproducible equivalent. You can hit the URL right now and watch the bug happen.

So here’s the thing about Supabase keys

Supabase gives you two keys when you create a project. The dashboard puts them right next to each other. They look almost identical — both are long JWT strings starting with eyJ, both have the same project URL, both feel like things you copy and paste once and forget.

One of them is fine to put in the browser. The other one is the database admin key.

I cannot tell you how many times I have opened DevTools on an app, hit view-source, and watched both of those keys come down in the JavaScript bundle. It happens on Lovable apps. It happens on Bolt apps. It happens on Cursor-generated apps. It happens enough that we built a scanner for it and it found enough customers to justify the build.

The reason it keeps happening is not that builders are careless. It is that the AI is helpful in exactly the wrong way.

The autocomplete that costs you everything

Picture the scene. You are using Lovable, or Cursor, or any AI-codegen tool. You ask it to add an admin dashboard. The AI writes the dashboard. It tries to query a users table. Row Level Security blocks it, because RLS is doing its job — the anon key shouldn’t be reading admin-only data.

The AI sees an error. The AI does not say “let’s set up an admin role and a policy that grants it access.” The AI knows there is another key in your .env file that doesn’t have this problem. So it writes:

const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY  // <-- here
)

The error goes away. The dashboard works. You ship.

What you have actually shipped is a database admin credential to every browser that loads your app. Not “a credential that lets users do admin things if they are logged in as admin.” A credential that bypasses Row Level Security entirely, for everyone. Anyone who opens DevTools, copies the key, and points the Supabase JS client at your URL can read every row in every table you own. They can write too. They can drop tables, depending on Postgres permissions.

If you go look at https://gapbench.vibe-eval.com/site/supabase-clone/ right now — open it in a browser, view source — you will see exactly this pattern. The key is in the bundle. The key is a JWT. Decode it on jwt.io. The role claim says service_role. That is the bug.

The clean version of the same thing is at https://gapbench.vibe-eval.com/site/ref-rls/. Same Supabase, same Next.js, same UI shape. No service-role key in the bundle. RLS configured. That’s what good looks like.

Why this isn’t just “the AI made a mistake”

There’s a temptation to write this off as the AI being dumb. It isn’t. The AI did exactly what you’d do at 2am with a deadline — it picked the path that made the error stop. The mistake is upstream: the env var is named in a way that makes it look bundleable, the framework happily bundles anything prefixed with VITE_ or NEXT_PUBLIC_ without warning, and the cost of getting it wrong only shows up in a later scan.

The deeper failure is that there is no friction. Putting the wrong key in the bundle should be loud. It is silent. The build succeeds. The deploy succeeds. The app works. The dashboard you just shipped will function correctly for months, even years, until someone — researcher, attacker, or auditor — opens DevTools.

This is the failure mode of AI codegen in a single sentence: it removes the friction that used to make you stop and think. The wrong choice is now as fast as the right one.

A specific incident

Anonymized version of one we found. A founder built a Lovable app, shipped it, picked up a few hundred users in the first month. He ran VibeEval against it because the trial was free and the idea of “let’s just check” felt cheap. Eight findings came back. Seven were standard — a missing CSP, a permissive CORS rule, a couple of rate-limit gaps. The eighth was the service-role key in the bundle.

The way the bug had landed in his codebase: he had asked Lovable to add a “send weekly digest email” feature. The feature needed to read every user’s recent activity, summarize it, and email it. Reading every user’s activity violates RLS by definition — RLS only lets users read their own. The AI’s resolution was to switch the Supabase client to use the service-role key inside the digest function. Sensible, except the function was a Next.js API route, and the API route was bundled with the rest of the frontend, and Lovable’s build pipeline didn’t separate the env vars by route. The key landed in the production JavaScript bundle for every page.

He had been live for 41 days when we found it. He rotated the key the same day, moved the digest function to a Supabase Edge Function with the new key never reaching the client, and re-ran the scan to confirm. Nothing in the post-mortem suggested anyone else had found the leak first — but he had no way to be sure, because the key didn’t get logged when used. The scariest part was the gap between deploy and detection.

Wrong fix vs right fix

The instinct, when you find this, is to “rotate the key and move the call to a server function.” That’s correct as far as it goes. The trap is the server function part. Several patterns look like they fix the bug but don’t:

// WRONG: still ships to the client
// app/api/admin/users.ts is rendered on the server
// but Next.js may include this file's imports in client bundles
// if it's imported anywhere else in the tree
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(URL, process.env.SUPABASE_SERVICE_ROLE_KEY)
// WRONG: env var is server-only but the call is from a route
// that gets pre-rendered with the env var inlined
export const config = { runtime: 'edge' }  // OK, but not enough
// RIGHT: env var server-only + call inside a Supabase Edge Function
// Function runs in Supabase's environment with its own secret manager
// The Next.js bundle never sees the key
// supabase/functions/digest/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
const supabase = createClient(
  Deno.env.get('SUPABASE_URL')!,
  Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)

The pattern that works: the service-role key never lives in any environment that produces a public artifact. Supabase Edge Functions, Cloudflare Workers with separated env, AWS Lambda with separate IAM — each of those has the property. Next.js API routes do not, by themselves, unless you carefully audit the import graph.

How the same bug looks on other stacks

Not Supabase-specific. The pattern repeats wherever a backend SDK has a “client safe” key and an “admin” key:

  • Firebase. firebaseConfig (public web key) versus the Admin SDK service-account JSON. We see the service-account JSON inlined into the bundle by AI codegen that imports firebase-admin from a Next.js component because the model wanted to “create a user from the signup form.”
  • Stripe. Publishable key (pk_live_...) versus secret key (sk_live_...). Same shape. Stripe at least screams in dev when you use the secret key from the browser, which catches some cases. Not all.
  • Algolia / Elasticsearch / Pinecone. Search-only API key versus admin API key. Search-only is browser-safe; admin grants write and schema-change. AI codegen swaps them when search-only “doesn’t work for this use case.”
  • OpenAI. No formal “browser-safe” key — every OpenAI key is server-only. AI codegen still embeds them in client code regularly because the example in the OpenAI docs uses fetch() and the model doesn’t always keep track of which side the fetch is running on.

The mitigation is the same across all of them: only the explicitly-public key reaches the bundle, and you have a CI check that fails the build if anything else does.

How we catch it

You don’t need a fancy scanner for this — although we sell one — you need a scanner that fetches your deployed JavaScript bundle, scans for JWT-shaped strings, decodes them, and reads the role claim. That is the whole detection. We run it as part of vibe-code-scanner. The token-leak-specific tool at /token-leak-checker/ does just this and produces a report.

The detection internals, in case you want to write your own: fetch every <script src> from the rendered HTML. For each script, scan for the regex eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ — the JWT-shape pattern. For each match, base64-decode the middle segment, parse as JSON, read the role claim. If role === "service_role", finding. Repeat for firebase admin patterns, sk_live_ Stripe patterns, AWS access keys (AKIA... for IAM, ASIA... for STS), and so on. The whole token-leak detection is ~50 lines of Python. The reason most scanners don’t run it is that they don’t fetch deployed bundles at all — they read source.

Static scanners — Snyk, Semgrep, the GitHub secret-scanning push protection — will flag the env var in source. That helps if the developer notices. It does not help if the developer commits the file or if the AI quietly adds the env var to .env.local. The only place this bug can be definitively detected is in the deployed bundle, after the build has run, against the live URL. Heuristic scanners that only read source code don’t see the bundle.

This is half the reason we built gapbench. A static scanner cannot calibrate against https://gapbench.vibe-eval.com/site/supabase-clone/ because there is no source code for it to look at — there is a deployed surface. Either you scan the surface or you miss the bug.

What you should actually do

If you suspect you have this bug, the fix order matters and the rotation order matters more.

First, rotate the service-role key in the Supabase dashboard. Do this before you fix the code. The leaked key is already public — every minute it stays valid is a minute someone can read your database. Rotation invalidates it.

Second, find every callsite that used the service-role key. The AI probably used it for any operation that touched RLS-protected tables. Each of those needs to move to a Supabase Edge Function or a Next.js API route running on the server, where the new service-role key never reaches the client.

Third, verify the new bundle does not contain the new key. Same scan, different key. If the key still leaks, the build pipeline is wrong — usually because the env var is still prefixed VITE_ or NEXT_PUBLIC_ or because someone hardcoded it.

Fourth, write an RLS policy for the admin operation that no longer uses the service-role key. The pattern is: a Postgres role for “admin,” a function that checks the user’s claim against that role, and an RLS policy that grants the function access. The Supabase docs cover this in the auth section.

Fifth — and this is the one most teams skip — write a CI check that fails the build if any JWT in the bundle has a service_role claim. It is a one-liner: dump the bundle, grep for eyJ, decode each match, fail on role: "service_role". We publish a version of this in the Supabase RLS Checker tooling.

CWE / OWASP

  • CWE-200 — Information Exposure
  • CWE-798 — Hardcoded Credentials
  • CWE-522 — Insufficiently Protected Credentials
  • OWASP API Security Top 10 — API2:2023 Broken Authentication, API5:2023 Broken Function-Level Authorization

Reproduce it yourself

Live scenarios on the gapbench benchmark:

Run VibeEval against the vulnerable URLs and you should see the leak finding fire. Run it against ref-rls and you should see nothing. If your scan against your own app looks more like the first set than the second, you have the bug.

RUN IT YOURSELF

Each scenario below is live on the public benchmark. The commands are copy-paste ready. Outputs may evolve as we tune the scenarios; the bug stays.

Vulnerable scenario
curl -s https://gapbench.vibe-eval.com/site/supabase-clone/ | grep -oE "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" | head -2
expected Returns two JWTs. Decode the second on jwt.io — the role claim is service_role. That key is a database admin credential, and it is in the browser.
Clean control
curl -s https://gapbench.vibe-eval.com/site/ref-rls/ | grep -oE "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+" | head -2
expected Returns one JWT with role claim anon. No service_role anywhere in the bundle. Same Supabase shape, configured correctly.

COMMON QUESTIONS

01
What is the difference between the Supabase anon key and the service-role key?
The anon key is designed to be public — it is rate-limited, scoped, and assumes Row Level Security is enforcing access on the database. The service-role key bypasses Row Level Security entirely. It is a database admin credential. Anyone who has it can read every row in every table, regardless of what your RLS policies say. The anon key belongs in the browser; the service-role key never does.
Q&A
02
How does the service-role key end up in the frontend?
Two ways, mostly. First, the AI generator imports it from .env.local thinking it is the anon key — both end up as VITE_ or NEXT_PUBLIC_ prefixed env vars and Vite or Next.js then bundles them into the client. Second, the developer asks the AI to add an admin feature, the AI realizes RLS will block it, and 'fixes' the problem by switching to the service-role key in client code instead of moving the call server-side.
Q&A
03
How do I know if my service-role key is in the bundle?
View source on a deployed page, search for 'service_role' or for the JWT-shaped string that starts with 'eyJ'. If you see two long base64-looking strings near each other, paste them into jwt.io and read the role claim. If either one says service_role, you have a problem. The VibeEval token leak checker does this scan automatically against your live URL.
Q&A
04
Is rotating the key enough to fix it?
Rotation is mandatory but not sufficient. After rotation you have to find every place the key was used, replace the calls with anon-key calls plus a server-side proxy for the admin operations, and verify the new bundle does not contain the new key either. Rotating without fixing the root cause means the new key leaks on the next deploy.
Q&A
05
Will Row Level Security save me if the service-role key leaks?
No. The service-role key explicitly bypasses Row Level Security. That is its job — it is the key the database administrator uses to do schema migrations and admin tasks. RLS is the access control layer; the service-role key is the all-access pass that ignores the access control layer. If the service-role key is public, RLS is irrelevant to anyone holding it.
Q&A
06
Where can I see this happen on a real URL?
https://gapbench.vibe-eval.com/site/supabase-clone/ has a deliberately leaked service-role configuration so scanners can be calibrated against a known-positive surface. The companion clean control is at https://gapbench.vibe-eval.com/site/ref-rls/ — same Supabase shape, RLS configured correctly, no leaked keys.
Q&A
07
What CWE and OWASP categories does this map to?
CWE-200 (Information Exposure), CWE-798 (Hardcoded Credentials), CWE-522 (Insufficiently Protected Credentials). OWASP API Security Top 10 #2 (Broken Authentication) and #5 (Broken Function-Level Authorization) once the leaked key is used.
Q&A

SCAN YOUR FRONTEND FOR EXPOSED KEYS

We hit the deployed bundle and tell you which keys leaked. 60 seconds, no setup.

RUN TOKEN LEAK CHECKER